Completionの必要性

正直な話を言うと、SwiftUIでCompletionが使えなくてもさほど困らない。困らないのだが、あった方が嬉しいのである。

例えば、イカのような仕様を満たすビューを書きたいとする。

  • NavigationLinkを踏むとパスコード入力画面を表示
  • パスコードが合っていれば別のビューに遷移
  • 間違っていればそのままの画面を表示

要するにパスコードチェックを目的のビューとの間にはさもうというわけだ。

これは以下のようなコードを書けば実装することができる。

@State var isAuthorized: Bool = false
// 中略
ZStack {
    NavigationLink(destination: DestinationView(), isActive: $isAuthorized, label: { EmptyView() })
    PasscodeLock($isAuthorized)
}
1
2
3
4
5
6
7

パスコード認証が通ったかどうかの情報をStateで保持しておき、その値をPasscodeLock内で変化させる。通ればisAuthorizedtrueになり、trueになればNavigationLinkが動作して別のビューに遷移する。

ただ、これをやるとisAuthorizedという変数をビューに渡さなければいけないのがめんどうだし、何よりZStackを使って実装するのが如何にもゴミコードという感じがする。

PasscodeLockはパスコードが通ったかどうかだけをチェックしてほしいのである。

@State var isPresented: Bool = false
Button(action: { isPresented.toggle() }, label: { Text("AUTHORIZE") })
DestinationView()
    .passcodeLock(isPresented: $isPresented) { 
        PasscodeLockView() { completion in
            switch completion {
                case .finished:
                    break
                case .failure(let error):
                    print(error)
            }
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14

例えばこのような記述ができるとありがたい。パスコードが通ったどうかをcompletionで返し、その値によって親ビュー側で分岐処理を書きたい。

@State var isPresented: Bool = false
ZStack {
    NavigationLink(destination: DestinationView(), isActive: $isPresented, label: { EmptyView() })
    PasscodeLockView() { completion in
        switch completion {
            case .finished:
                isPresented.toggle() 
            case .failure(let error):
                print(error) 
        }    
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

こういう書き方もできる。が、これは結局ZStackを使っているのでゴミコード具合はあまり変わっていない気もする。

まあ実際にどうやって使うかはさておき、Completionを返すようなビューは書けるのかどうかが気になるわけである。似たような仕組みを持つものにBetterSafariView (opens new window)があり、これの書き方はかなり参考になる。

.webAuthenticationSession(isPresented: $startingWebAuthenticationSession) {
    WebAuthenticationSession(
        url: URL(string: "https://github.com/login/oauth/authorize")!,
        callbackURLScheme: "github"
    ) { callbackURL, error in
        print(callbackURL, error)
    }
    .prefersEphemeralWebBrowserSession(false)
}
1
2
3
4
5
6
7
8
9

これは要するにisPresentedの値がtrueであればWebAuthenticationSession()が呼び出され、それが閉じるときにcallBakcURLとerrorが返ってくるという仕組みになっている。

これはまさに求めていた仕様そのものである。

この部分を実装するソースコード (opens new window)を読んでみたのだが、正直言ってちんぷんかんぷんだった。

public init(
    url: URL,
    callbackURLScheme: String?,
    completionHandler: @escaping (_ callbackURL: URL?, _ error: Error?) -> Void
) {
    self.url = url
    self.callbackURLScheme = callbackURLScheme
    self.completionHandler = completionHandler
}
1
2
3
4
5
6
7
8
9

重要となるのはここで、イニシャライザでcompletionHandlerを指定しているのがわかる。で、ここまではわかるのだ。

self.completionHandlercompletionHandlerをくっつけているのだが、self.completionHandlerというのがよくわからないのである。

ソースコードを見るとこう書いてある。

public typealias CompletionHandler = ASWebAuthenticationSession.CompletionHandler // <- CompletionHandler
/// A completion handler for the web authentication session.
public typealias OnCompletion = (_ result: Result<URL, Error>) -> Void
// MARK: Representation Properties
let url: URL
let callbackURLScheme: String?
let completionHandler: CompletionHandler // <- CompletionHandler
1
2
3
4
5
6
7
8
9

typealiasというのはC++でいうところのdefineのようなものだと勝手に思っている。つまり、上のコードは以下のコードと等価ということになる。

let completionHandler = ASWebAuthenticationSession.CompletionHandler
1

だが困ったことに作ろうとしているPasscodeLockViewにはこのようなcompletionHandlerが存在しない。どうしたらいいのだろうか。

発展させる

ここまでの話は単にパスコードを入力するだけの機能を考えた場合の話である。実際にはもっと複雑なリクエストが要求される。

例えばPasscodeLock (opens new window)ではEnter, Set, Change, Removeの四つのモードがサポートされている。

これらはそれぞれ

  • Enter
    • パスコードを入力して一致するかチェックする
  • Set
    • 新たにパスコードを入力する
    • 古いパスコードは要求されない
  • Change
    • 設定されたパスコードを変更する
    • 古いパスコードが要求される
  • Remove
    • パスコードを入力する
    • キャンセルで処理を中断させられる

といった違いがある。Removeに関してはEnterとほとんど同じなのでここでは無視できるとして、これをSwiftUIに拡張しつつ使いやすさも兼ねたライブラリにするためには、

  • Enter
    • 引数
      • 現在のパスコード
      • 生体認証を使うかどうかのフラグ
    • 返り値
      • パスコードと一致したかどうか
  • Set
    • 引数なし
    • 返り値
      • 設定された新たなパスコード
  • Change
    • 引数
      • 現在のパスコード
    • 返り値
      • 再設定されたパスコード
      • パスコードと一致したかどうか
      • のどちらか(これはResultを使えば対応可能)

というような仕様を満たせば良いことになる。つまり、例えば以下のような実装が考えられる。

// Enter
PasscodeEnterView(passcode: passcode, withBiometrics: true) { result in
    // 成功したかどうかのフラグresultによって処理を変える
}
PasscodeSetView() { result in
    // resultに新たなパスコードが入っている
}
PasscodeChangeView(passcode: passcode) { result in
    // 成功したかどうかのフラグresultによって処理を変える
}
1
2
3
4
5
6
7
8
9
10
11
12

これらはまとめしまっても良いだろう。

// Enter
PasscodeView(state: .enter, passcode: passcode, withBiometrics: true) { result in
    // 成功したかどうかのフラグresultによって処理を変える
}
// Set
PasscodeView(state: .set) { result in
    // 成功したかどうかのフラグresultによって処理を変える
}
// Change
PasscodeView(state: .change, passcode: passcode) { result in
    // 成功したかどうかのフラグresultによって処理を変える
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

withBiometricsはオプショナルでデフォルト値をオフにしておけばいいし、setでは旧パスコードは不要だが無視するようにすればいい。

より良いのはイニシャライザを複数用意することだろう。

が、結局これは完了ハンドラが呼べないと使えない。

完了ハンドラを書いてみよう

書き方が合っているのかどうかはわからないんが、一応完了ハンドラ的なものは書けた。

以下はパスコードを入力して設定されたものと同じであればResultとしてsuccessを返し、間違っていればfailureを返すようなものである。

import SwiftUI
struct ContentView: View {
    // 完了ハンドラを決定する
    typealias CompletionHandler = (Result<Bool, Error>) -> Void
    let completionHandler: CompletionHandler
    // パスコードは5にしておく
    private var passcode: Int = 5
    init(completionHandler: @escaping CompletionHandler) {
        self.completionHandler = completionHandler
    }
    var body: some View {
        GeometryReader { geometry in
            LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 60, maximum: 80), spacing: 0), count: 3), alignment: .center, spacing: 10, pinnedViews: []) {
                ForEach(Range(1...9)) { number in
                    Button(action: { addSign(sender: number)}, label: { Text("\(number)").frame(width: 60, height: 60, alignment: .center) })
                        .overlay(Circle().stroke(Color.blue, lineWidth: 1))
                }
                .buttonStyle(CircleButtonStyle())
                Button(action: { biometricsAuth() }, label: { Image(systemName: "touchid").resizable().frame(width: 40, height: 40, alignment: .center) })
                Button(action: { addSign(sender: 0) }, label: { Text("0").frame(width: 60, height: 60, alignment: .center) })
                    .buttonStyle(CircleButtonStyle())
                Button(action: {}, label: { Text("Delete").frame(width: 60, height: 60, alignment: .center) })
            }
            .position(x: geometry.size.width / 2, y: geometry.size.height / 2)
        }
        .edgesIgnoringSafeArea(.all)
        .background(Color.white)
    }
    func addSign(sender: Int) {
        // ボタンを押したときの処理
        if sender == passcode {
            // 一致していればSuccess(True)を返す
            completionHandler(.success(true))
        } else {
            // 一致していなければSuccess(False)を返す
            completionHandler(.success(false))
        }
    }
}
// ボタンをかっこよくするためだけのコード
struct CircleButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundColor(configuration.isPressed ? Color.white : Color.blue)
            .overlay(Circle().stroke(Color.blue, lineWidth: 1))
            .contentShape(Circle()
            .background(Circle().foregroundColor(configuration.isPressed ? Color.blue : Color.clear))
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

ここで大事なのは「一致していなければエラーを返す」というわけではないということである。あくまでもエラーというのは想定していない挙動をしたときに返すべきである。

なので、パスコードが一致しなかった場合にはパスコードチェックプロセスは正しく動作したが、パスコードが間違っていたという意味でsuccess(false)を返す方が良いのではないかと考えた。

で、めちゃくちゃ話がとぶのだがこのコードを書けるようになるまでに随分苦労した。このような処理が必要になる場面は多々あると思うのだが、"SwiftUI completion", "SwiftUI closure"などと探しても全く参考文献が見つからないのだ。

まじでこれどうやって書くんだと悩んでいたとき、ふとBetterSafariViewのコードを見ていてひらめいたのである。

public typealias CompletionHandler = ASWebAuthenticationSession.CompletionHandler // <- CompletionHandler
/// A completion handler for the web authentication session.
1
2

この部分でCompletionHandlerを設定しているのだが、ASWebAuthenticationSession.CompletionHandlerはあくまでもASWebAuthenticationSessionの完了ハンドラなので使えない。が、完了ハンドラ自体を自分で定義すればよいのではないかと。

この完了ハンドラ自体はAppleのドキュメント (opens new window)に載っていたのですぐに特定できた。

すると、これは単に以下のコードであることがわかった。要するに、完了ハンドラはこう書けばいいのである。

public typealias CompletionHandler = (URL?, Error?) -> Void
1

そしてこのコードを見ていてふと思い出したのがこの部分の謎コード (opens new window)でした。

コピペせずに頑張って手打ちしていたのが功を奏したと言えます。コピペしていたら記憶に残ることはなかったでしょう。