🐡

SwiftUIでPasscodeのサンプルを作ってみた

2022/04/11に公開

ソースコード

https://github.com/mitolog/SwiftUI-Passcode

なぜ作ったか

iOSのtouchIDやfaceIDなどの生体認証と連携すれば簡単かもしれないけど、今回は独立した機能としてのパスコード画面が必要でした。

また、現在進行中のSwiftUI + Clean Architecture + Combine なプロジェクトだったのですが、SwiftUI製のいい感じのサンプルやライブラリがなかったので、作成してみました。といってもUIの部分だけがゼロベースであとは既存の組み合わせでうまく機能してくれただけなんですが!

どんな機能?

  • 画面をロックしたり、ホームボタンで閉じて、再度アプリをひらいたときにパスコード画面がでてくる
  • 0~9の数値を任意の桁数で入力してアンロックする
  • 間違えた場合は、シェイクアニメーションが走り、入力された数値がリセットされる
  • フォントや色などのパラメータをinit時に指定できる

ざっくりいうとこんな感じです。動きは以下のgifを確認ください

仕組み(というかもはや謝辞)

UI

コアの部分は Passcodeフォルダ配下にある

  • PasscodeField.swift
  • LegacyTextField.swift
  • ShakeAnimation.swift

この3つのファイルで構成されています。

PasscodeField が本体で、SwiftUIとCombineで構成されています。細かいUIの説明はソースコードをみた方がはやいと思うので割愛しますが、こだわりポイントとして2点だけ。カーソルの点滅具合と、入力された数値が時間差で●に変わるところはアニメーションの仕組みを理解しながら頑張りました!

LegacyTextField は、画面表示時にキーボードを出すためにUIResponderを利用したかったので、stackoverflowの記事と、hackingwithswiftの記事をほぼそのまま引っ張ってきています。

※ 今回はiOS14もサポートしたかったので、そのようにしましたが、iOS15以降であれば、@FocusState を使うと良さそうです。

ShakeAnimationは、皆さんおなじみstack overflowより。そして、シェイクアニメーションの後、入力したパスコードをリセットしたかったので、ANTOINE VAN DER LEE さんの記事より抜粋。

windowの取り回し

.fullScreenCover試してみたり、専用のView(Struct)を作って、GeometryReader的な感じで必要なViewで呼び出してみたりもしたんですが、結局windowを使うと影響範囲が小さくすむし、挙動がスムーズだったので、windowを重ねて表示する方法を採用しました。

方法は@zntfdrさんの、How to layer multiple windows in SwiftUIをほぼそのまま採用しました。

windowの取り回しが垣間見えるのは、

  • SceneDelegate.swift
  • PassThroughWindow.swift

です。

考え方としては、PassThroughWindowをメインのWindowの上に常に置いておき、Passcodeを非表示としたいときは、背景色をClearとします。それによって、下にあるメインのWindow配下のviewが見えるようになっています。

ではどうやってタッチイベントをメインのWindowにおくっているかといと、PassThroughWindowがオーバーライドしているのhitTestです。

class PassThroughWindow: UIWindow {
  override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    guard let hitView = super.hitTest(point, with: event) else { return nil }
    return rootViewController?.view == hitView ? nil : hitView
  }
}

nilを返すと、PassThroughWindowはこのタッチイベントに関与しないことを宣言し、もう一方のメインのWindowに処理が渡るようです。

hitViewには、

  • パスコード表示時はPasscodeView配下の何かしらのview
  • パスコード非表示時は、PasscodeViewのrootView(clear指定したview)

が入ってくるので、結果として、意図した通りに動作するようになっています。

ステートの共有

サンプルでは、特にステートをwindow間で共有してないですが、SceneDelegate.swiftで let appState = Store<AppState>(AppState()) とすることでsingle source of truth として扱い、各viewに振り分けられるようにしています。

ステートの共有は、実プロジェクトでは https://github.com/nalexn/clean-architecture-swiftui をベースにしています。今回はあくまでサンプルなので、考え方として必要な部分だけ引っ張ってきました。

今後

需要があれば、

  • テスト書く
  • SwiftPackageに登録する

などしたいです。なので、いい感じに使いたければgithubでstarつけてね!

Discussion