ObjecTips

Swift & Objective-C で iOS とか macOS とか

async/await への自動変換

System Framework以外でも自動変換が行われるケースに遭遇したけど自動変換されるものとされないものの違いが分からなかったので調査。

Xcode 13 Release Notes には以下のように記載がある。 https://developer.apple.com/documentation/xcode-release-notes/xcode-13-release-notes

Swift translates an Obj-C method that delivers its results asynchronously via a completion handler into an async method that directly returns the result (or throws). (78028295)

For example, Swift translates the following Objective-C method from CloudKit:

- (void)fetchShareParticipantWithUserRecordID:(CKRecordID *)userRecordID
    completionHandler:(void (^)(CKShareParticipant * _Nullable, NSError * _Nullable))completionHandler;

completion handler で非同期な結果を配信するObjective-Cメソッドと記載がある。

調査

調査環境 - iMac (24-inch, M1, 2021), macOS Monterey 12.3.1, Xcode 13.3.1

サンプルプロジェクトを作って確認。

  • プロジェクトテンプレートからiOSアプリのSwift指定でプロジェクトを作成
  • Objective-Cのクラスファイルを作成して completion handler を使ったメソッドを実装
#import <Foundation/Foundation.h>
@interface MyObject : NSObject
- (void)myMethod:(void (^)(NSString * _Nullable, NSError * _Nullable))compleitonHandler;
@end
  • Bridging-Header.h ファイルで #import "MyObject.h" をしてSwiftから呼び出せるようにする
  • Swiftから MyObject().my まで入力して Xcode の autocompletion を表示

asyncへの自動変換は行われなかった。


と次の手を考えている時にメソッド名の typo に気付く。
compleitonHandlercompletionHandler に修正。

- (void)myMethod:(void (^)(NSString * _Nullable, NSError * _Nullable))completionHandler;

すると! 変換された!
引数名が completionHandler でないといけない!?


そこで引数名を completion にしてみる OK


引数名を completionBlock にしてみる OK


引数名を handler にしてみる NG
あれ?最初が completion だったらなんでもいけるんじゃないのか?


引数名を completionFoo にしてみる NG
何でもはダメなのか、、


引数名を block にしてみる NG


とりあえず completionHandler completionBlock completion の3つはOKという事は分かった。


Nullabilityの調査

少し意地悪をして返り値とエラーの両方が _Nonnull だったら?

- (void)myMethod:(void (^)(NSString * _Nonnull, NSError * _Nonnull))completionHandler;

エラー無しで tuple で2つの値を返すasyncメソッドに変換された。


_Nullable を書いていない場合

- (void)myMethod:(void (^)(NSString *, NSError *))completionHandler;

この場合も先ほどと同じく tuple


という事で Nullability を適切に記載しているというのも条件になる。


Swiftコードの調査

Objective-Cの実装を削除してSwiftのclassを作成する

class MyObject {
}

メソッドを実装

    func myMethod(_ completionHandler: @escaping (String?, Error?) -> Void) {
    }

これを呼び出してみる NG


@objc を付けてみる

    @objc func myMethod(_ completionHandler: @escaping (String?, Error?) -> Void) {
    }

NG


NSObject を継承してみる

class MyObject: NSObject {

NG


NSString NSError にしてみる

    @objc func myMethod(_ completionHandler: @escaping (NSString?, NSError?) -> Void) {
    }

NG


Resultにしてみる

    func myMethod(_ completionHandler: @escaping (Result<String, Error>) -> Void) {
    }

NG


ドキュメントにはObjective-Cメソッドと記載があるので、Swiftでの実装だろうと @objc 宣言でObjective-Cメソッドとして扱えば自動変換してくれそうに思えるけどSwiftで実装されたものは自動変換してくれないみたい。Swiftコードのasync/awaitへの自動変換があると移行に面倒がなくて良いんだけど残念。

async/await への自動変換の条件まとめ

  • Objective-Cで書かれている
  • Nullabilityが正しく設定されている
  • 引数名は completionHandler, completionBlock, completion のいずれか

Task.init と Task.detached の書き方バリエーション

以下5つは同じ

Task {
}

Task.init {
}

Task.init() {
}

// deprecated
async {
}


// deprecated
async() {
}

以下6つは同じ

Task.detached {
}

Task.detached() {
}

// deprecated
Task.runDetached {
}

// deprecated
Task.runDetached() {
}

// deprecated
detach {
}

// deprecated
detach() {
}

いずれも引数の priority を省略して operation を Trailing Closure で省略表記した形。
WWDCでの発表から正式リリースまでの間に書かれた記事やコードはこの辺ばらつきがあるの読む時は注意。*1

まとめ

シンプルに以下2つを記憶に残して他は忘れて良し

Task {
}

Task.detached {
}

*1:注意というか軽く読み流す感じで

Swift の関数の override で引数のデフォルト値を変更すると何が起こるか

Xcode engineer の人がこんなツイートをしていた

早速 Playground で確認

まずクラスとメソッドを定義*1

import Foundation

class Base {
    func test(i: Int = 1) {
        print("Base class i: \(i)")
    }
}

class Sub: Base {
    override func test(i: Int = 2) {
        print("Sub class i: \(i)")
    }
}

通常のインスタンス化と呼び出し

let base = Base()
base.test()
// Base class i: 1

let sub = Sub()
sub.test()
// Sub class i: 2

ここまでは普通


問題は次
サブクラスでインスタンス化しつつ親クラスを型として宣言
そしてメソッドを呼ぶと...

let a: Base = Sub()
a.test()
// Sub class i: 1

インスタンスはサブクラスの Sub でメソッドもサブクラスの Sub.test() が呼ばれるけど、デフォルト引数は親クラス Base で指定されている値が使われる!

以下を試してみる

let b = Sub()
b.test()
// Sub class i: 2

let c: Base = b
c.test()
// Sub class i: 1

print(b === c)
// true

同一インスタンスの参照であるため === で比較すると true になる。 つまり実体は1つ。
でも宣言型によってメソッドを呼んだ時に適用されるデフォルト引数が変化している。
個人的には直感に反した挙動でいつか落とし穴になってしまいそう。*2


ちなみに Kotlin だと親クラスでデフォルト引数が指定されていようがいまいが override された関数ではデフォルト引数を指定できない仕様になっているらしく、不意の事故が起こる心配が無い。


ドキュメントでの記述が見当たらなかったので Swift Forum を当たってみたところ、2018年3月に話題に上がっていた模様。*3

https://forums.swift.org/t/pitch-allow-default-parameter-overrides/10673

*1:シンプルにするため super.test() は省いた

*2:ドキュメントを探したけどこの挙動の仕様について明記されている場所は見つからず
https://docs.swift.org/swift-book/LanguageGuide/Functions.html#ID169
https://docs.swift.org/swift-book/LanguageGuide/Inheritance.html#ID196

*3:内容は確認できていないけど C++ と同じ挙動だそう

SwiftUI でアプリ全体のアクセントカラーを指定する Accent color for the entire SwiftUI app

新規アプリで Xcode のテンプレートからプロジェクトを作成すると Assets.xcassets に AccentColor という名称のカラーアセットが用意されている。これに色を設定するとアプリ全体の AccentColor として反映される。

f:id:Koze:20210709233353p:plain

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            Button("Button") {
            }
            .toolbar {
                ToolbarItem(placement: .automatic) {
                    Button(action: {}, label: {
                        Text("Button")
                    })
                }
            }
        }
    }
}

f:id:Koze:20210709233802p:plain:w300

既存プロジェクトの場合

既存プロジェクトの場合は同名のカラーアセット AccentColor を用意するだけでは反映されない。
プロジェクトテンプレートのビルド設定を見ると Global Accent Color Name という項目がありそこに AccentColor の名称が指定されていた。
設定キーは ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME らしい。 ビルド時にはこの値を見てコンパイルされる。

f:id:Koze:20210709234744p:plain

なのでカラーアセットの名称も AccentColor 固定ではなくてここで自由に設定する事ができる。*1

*1:記事タイトルに SwiftUI と入れたけどこの設定は UIKit でも同じ

Swift での範囲内の数値の判定バリエーション

Comparison operator + logical AND operator

他の言語と同じ < > <= >= && を使うパターン

条件 比較演算子+論理和演算子
0以上1以下 if 0.0 <= value && value <= 1.0
0以上1未満 if 0.0 <= value && value < 1.0
0超え1以下 if 0.0 < value && value <= 1.0
0超え1未満 if 0.0 < value && value < 1.0

Comparison Operator
https://docs.swift.org/swift-book/LanguageGuide/BasicOperators.html#ID70

Logical AND Operator
https://docs.swift.org/swift-book/LanguageGuide/BasicOperators.html#ID78

Comparison operator + comma

Swift ではカンマ , で条件を列挙できる *1

条件 比較演算子+論理演算子
0以上1以下 if 0.0 <= value, value <= 1.0
0以上1未満 if 0.0 <= value, value < 1.0
0超え1以下 if 0.0 < value, value <= 1.0
0超え1未満 if 0.0 < value, value < 1.0

Range + nextUp, nextDown

もう一つ Swift で Range ClosedRange を使ったパターン
肝は FloatingPointnextUpnextDown
nextUp はその値より大きい最も小さな値
nextDown はその値より小さい最も大きな値

条件 Range+nextUp(Down)+パターンマッチング
0以上1以下 if case 0.0...1.0 = value
0以上1未満 if case 0.0..<1.0 = value
0超え1以下 if case 0.0.nextUp...1.0 = value
0超え1未満 if case 0.0.nextUp..<1.0 = value
0超え1未満 if case 0.0.nextUp...1.0.nextDown = value

ちなみに、「対象の値」と「対象の値より大きい最も小さな値」の差分って何?
nextUp nextDown によってどう値が変化するの?
という疑問への答えは以下。

0.0.nextUp == .leastNonzeroMagnitude // true

覚えてしまえば1つの条件式で書けてしまうので楽かも?

Range (half-open range)
https://developer.apple.com/documentation/swift/range

ClosedRange
https://developer.apple.com/documentation/swift/closedrange

nextUp
https://developer.apple.com/documentation/swift/floatingpoint/1848104-nextup

nextDown
https://developer.apple.com/documentation/swift/floatingpoint/3017979-nextdown

leastNonzeroMagnitude
https://developer.apple.com/documentation/swift/floatingpoint/1848591-leastnonzeromagnitude

追記

Range + nextUp, nextDown + ~=

記事を見たiOSエンジニアの人から他のパターンも教えてもらった
if case 0.0...1.0 = value
これを演算子 ~= を使って
if 0.0...1.0 ~= value
と置き換え可能との事。

置き換え版は以下

条件 Range+nextUp(Down)+パターンマッチング
0以上1以下 if 0.0...1.0 ~= value
0以上1未満 if 0.0..<1.0 ~= value
0超え1以下 if 0.0.nextUp...1.0 ~= value
0超え1未満 if 0.0.nextUp..<1.0 ~= value
0超え1未満 if 0.0.nextUp...1.0.nextDown ~= value

Appleのドキュメントによれば ~= はパターンマッチング演算子 (pattern-matching operator) で
case statement の書き方のパターンマッチングは内部でこの演算子を使用しているらしい。
勉強になった。

~=(::)
https://developer.apple.com/documentation/swift/1539154

*1:ドキュメントの記載場所は見当たらなかった。どこかに書いてあるんだろうけど。。

UIAction と UIControlEvent

少し調査部分が長くなってしまったのでざっと読みたい人は「まとめ」の段をどうぞ


UIButton でメソッドの実行を設定するには親クラスの UIControl で定義されている以下のメソッドを使用する。

func addTarget(_ target: Any?, action: Selector, for controlEvents: UIControl.Event)

https://developer.apple.com/documentation/uikit/uicontrol/1618259-addtarget

実装例

let button = UIButton(type: .system)
button.addTarget(self, action: #selector(someMethod), for: .touchUpInside)

iOS 14 では UIAction を使った代替メソッドが UIControl に追加されている。

func addAction(_ action: UIAction, for controlEvents: UIControl.Event)

実装例

let button = UIButton(type: .system)
let action = UIAction(title: "title") { action in
    // do something
}
button.addAction(action, for: .touchUpInside)

暗黙的なトリガータイミングの指定

UIControl には addAction メソッドの他に以下のイニシャライザが追加されている。

convenience init(frame: CGRect, primaryAction: UIAction?)

https://developer.apple.com/documentation/uikit/uicontrol/3600494-init

このイニシャライザを使用した場合 UIControlEvent の指定がされていない。
ではどのタイミングで処理が発生するのか。
以下のコードでイニシャライザを使って UIAction を指定した時の UIControlEvent を確認してみる。

let action = UIAction(title: "title") { action in
    // do something
}
let button = UIButton(frame: frame, primaryAction: action)
print(button.allControlEvents) // UIControlEvents(rawValue: 8192)

結果は UIControlEvents(rawValue: 8192) となった。
これは UIControlEventprimaryActionTriggered の値と一致する。

@available(iOS 9.0, *)
public static var primaryActionTriggered: UIControl.Event { get }

https://developer.apple.com/documentation/uikit/uicontrol/event/1618222-primaryactiontriggered

ドキュメントには以下のように書かれているが説明が短い。

A semantic action triggered by buttons.

調べてみたところ以下のメソッドのヘッダコメントにもう少し説明があった。

@available(iOS 14.0, *)
public convenience init(frame: CGRect, primaryAction: UIAction?)

Initializes the control and adds primaryAction for the UIControlEventPrimaryActionTriggered control event. Subclasses of UIControl may alter or add behaviors around the usage of primaryAction, see subclass documentation of this initializer for additional information.

ちゃんと UIControlEventPrimaryActionTriggered を使うという記載があったが、じゃあそのトリガーのタイミングがいつかという具体的な説明はされていない。
サブクラスのドキュメントを見ろと書いてあるので見てみる。

UIButton

convenience init(frame: CGRect, primaryAction: UIAction?)

https://developer.apple.com/documentation/uikit/uibutton/3600349-init

The action to perform when the button is selected. The button registers this action for the primaryActionTriggered control event and sets the title and image properties to the action’s title and image.

という事で、ボタンが選択された時に実行すると書かれている。

UISegmentedControl

convenience init(frame: CGRect, actions: [UIAction])

https://developer.apple.com/documentation/uikit/uisegmentedcontrol/3600580-init

No overview available.

ドキュメント無し。
ヘッダコメントの方に記述があった。

Initializes the segmented control with the given frame and segments constructed from the given UIActions. Segments will prefer images over titles when both are provided. Selecting a segment calls UIAction.actionHandler as well as handlers for the ValueChanged and PrimaryActionTriggered control events.

選択すると valueChangedPrimaryActionTriggered の両方がトリガーされるらしい。

調査結果

結局 UIControlEventPrimaryActionTriggered の詳細な説明は見つからなかった。
コードを書いて試してみたところ実際の動作としては UIButton は選択時、おそらく touchUpInside に相当。
UISegmentedControl UISwitch UIStepper UIDatePicker も選択時で valueChanged に相当。
UISlider はスライダー操作時にトリガーで、挙動を見た感じでは valueChanged 以外に touchDown touchUpInside touchUpOutside あたりも入っていそうな動きをしていた。
最後に UITextField はキーボードのリターンキーを押したタイミングでトリガーされ、テキスト入力のフォーカスは外れないという挙動だった。UITextField + editingDidEnd の場合はフォーカスが外れた時にトリガーされ、editingDidEndOnExit はフォーカスが外れただけではトリガーされずにリターンキーを押すとトリガーされつつフォーカスが外れるという挙動になるので primaryActionTriggered はこれらとは異なった挙動になる事が分かった。

まとめ

iOS 14.5で確認

primaryAction を使ったイニシャライザで UIAction を設定すると暗黙的に UIControlPrimaryActionTriggered が設定される。

Implicitly using UIControlEventPrimaryActionTrigg…

UIControlPrimaryActionTriggered はクラスによって挙動が異なる。

Class UIControlPrimaryActionTriggered と同じような挙動の UIControlEvent
UIButton touchUpInside
UISegmentedControl, UISwitch, UIStepper, UIDatePicker valueChanged
UISlider [valueChanged, touchDown, touchUpInside, touchUpOutside]
UITextField None

UITextField の場合 UIControlPrimaryActionTriggered と同じ挙動になる UIControlEvent は無い。

UITextField の UIControlEvent リターンキーを押した時の挙動 トリガーのタイミング
editingDidEnd フォーカスが外れない フォーカスが外れた時
editingDidEndOnExit フォーカスが外れる リターンキーを押してフォーカスが外れた時(リターンキー以外でフォーカスが外れた場合はトリガーされない)
primaryActionTriggered フォーカスが外れない リターンキーを押した時

UISliderUITextFieldUIControlPrimaryActionTriggered を使用する時は単純に単一の UIControlEvent を用いた時とは挙動が異なる事を頭の片隅に置いておいた方が良いかも知れない。
また、これらの挙動の違いを理解した上で初期化済みのインスタンスに対して UIControlPrimaryActionTriggered でアクションを設定したいという場合は以下の様に実装する事も可能。

let action = UIAction(title: "title") { action in
    // do something
}
let textField = UITextField(frame: frame)
textField.addAction(action, for: .primaryActionTriggered)

以上

Xcode 12 の SwiftUI + Core Data のプロジェクトテンプレートが不完全

新規プロジェクト作成でプロジェクトテンプレートから iOS App を選択

f:id:Koze:20210427063905p:plain:w600

Interface SwiftUI, Life Cycle SwiftUI App, Language Swift を選択して Use Core Data をチェック

f:id:Koze:20210427063950p:plain:w600

作成される初期画面 ContentViewbody とビルド結果は以下

    var body: some View {
        List {
            ForEach(items) { item in
                Text("Item at \(item.timestamp!, formatter: itemFormatter)")
            }
            .onDelete(perform: deleteItems)
        }
        .toolbar {
            #if os(iOS)
            EditButton()
            #endif

            Button(action: addItem) {
                Label("Add Item", systemImage: "plus")
            }
        }
    }

f:id:Koze:20210427075543p:plain:w400

画面が真っ白で何も表示されない。

1つ目の原因は NavigationView の指定が無いこと。toolbar のコンテンツを表示するには NavigationView が必要なのでまず body の中身のルートの ListNavigationView に入れる。

    var body: some View {
        NavigationView {
            List {
                // 省略
            }
            .toolbar {
                // 省略
            }
        }
    }

f:id:Koze:20210427080655p:plain:w400

編集ボタンが表示された。でもまだ項目追加のプラスボタンが表示されていない。

次に toolbar のコンテンツの各ボタンを ToolbarItem に入れる。引数の placement は今回は navigationBarLeading navigationBarTrailing を使用。

    var body: some View {
        NavigationView {
            List {
                // 省略
            }
            .toolbar {
                #if os(iOS)
                ToolbarItem(placement: .navigationBarLeading) {
                    EditButton()
                }
                #endif
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        }
    }

f:id:Koze:20210427083621p:plain:w400

プラスボタンを押すと後はテンプレートの実装通り動作しリストが表示される。

f:id:Koze:20210427083802p:plain:w400

これから初めて Swift/SwiftUI をやるぞって時にテンプレートがこの有様だとスタートでつまづいてしまうのでヘルプになればと思いシェアです*1

*1:このテンプレートの不具合が気になった人は Feedback Assistant にレポートしてくれると幸い