ObjecTips

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

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 にレポートしてくれると幸い

Xcode 11 で iOS 14 Simulator を使用する

Xcode 12 への移行はまだ先の予定だけど取り敢えず iOS 14 で動作確認・修正作業をしたいなんてケースがある。
そこでタイトルの「Xcode 11 で iOS 14 Simulator を使用する」これができるらしい。

stackoverflow.com

手順

  • Xcode 12 付属の iOS Simulator を起動 *1
    • Xcode 12 を起動して Xcode > Open Developer Tool > Simulator を選択して起動
    • もしくは Xcode 12 内臓の Xcode.app/Contents/Developer/Applications/Simulator.app を起動
  • Simulator.app で File > Open Simulator > iOS 14.0 から任意のデバイスを選択して開く
  • Xcode 12 を起動していたら終了する
  • Xcode 11 を起動する
  • Xcode 11 の Run Destination のリストに起動中の iOS 14 Simulator が表示されて Run できる

メリット

  • Xcode 11(iOS 13 SDKビルド)+ iOS 14 の組み合わせでの動作確認が簡単にできる
  • iOS 14 beta を入れた実機をたくさん用意しなくて良い

Xcode 12 と iOS 14 のリリース後もプロジェクトの完全移行までしばらくの間 Xcode 11(iOS 13 SDK)を使ってビルドを続けるケースはままあるのでこの技はしばらく使えそう。

*1:Xcode 12 の iOS Simulator はドックに入れておくと次から楽