Quantumleap
2383 words
12 minutes
SwiftUIでCompletionを使おう
2021-04-13

Completion の必要性#

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

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

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

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

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

@State var isAuthorized: Bool = false
// 中略

ZStack {
    NavigationLink(destination: DestinationView(), isActive: $isAuthorized, label: { EmptyView() })
    PasscodeLock($isAuthorized)
}

パスコード認証が通ったかどうかの情報を 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)
            }
        }
    }

例えばこのような記述ができるとありがたい。パスコードが通ったどうかを 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)
        }
    }
}

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

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

.webAuthenticationSession(isPresented: $startingWebAuthenticationSession) {
    WebAuthenticationSession(
        url: URL(string: "https://github.com/login/oauth/authorize")!,
        callbackURLScheme: "github"
    ) { callbackURL, error in
        print(callbackURL, error)
    }
    .prefersEphemeralWebBrowserSession(false)
}

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

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

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

public init(
    url: URL,
    callbackURLScheme: String?,
    completionHandler: @escaping (_ callbackURL: URL?, _ error: Error?) -> Void
) {
    self.url = url
    self.callbackURLScheme = callbackURLScheme
    self.completionHandler = completionHandler
}

重要となるのはここで、イニシャライザで 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

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

let completionHandler = ASWebAuthenticationSession.CompletionHandler

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

発展させる#

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

例えばPasscodeLockではEnterSetChangeRemoveの四つのモードがサポートされている。

これらはそれぞれ

  • 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によって処理を変える
}

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

// 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によって処理を変える
}

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))
    }
}

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

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

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

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

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

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

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

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

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

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

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

SwiftUIでCompletionを使おう
https://fuwari.vercel.app/posts/2021/04/completion/
Author
tkgling
Published at
2021-04-13