- 投稿日:2020-07-06T22:59:21+09:00
UserDefaultってすっごくシンプル!?(追加と取得)
UserDefaultの書込み
って本当は簡単なのかもしれない。と思い、簡略化して書いてみました。
View1Controller.swiftimport UIKit class ViewController: UIViewController,UITextFieldDelegate { @IBOutlet weak var emailTextField: UITextField! var emailTextField = "" override func viewDidLoad() { super.viewDidLoad() emailTextField.delegate = self } @IBAction func ButtonAction(_ sender: Any) UserDefaults.standard.set(emailTextField.text, forKey: "userMail")UserDefault.standard.set で保存(キー値はここでは"userMail")
これだけUserDefaultの取得も全然簡単!?
View2Controller.swiftimport UIKit class View2Controller: UIViewController,UIImagePickerControllerDelegate,UINavigationControllerDelegate { @IBOutlet weak var acountEmail: UILabel! var userMail = String() override func viewDidLoad() { super.viewDidLoad() userMail = UserDefaults.standard.object(forKey: "userMail") as! String acountEmail.text = userMail }取得もこれだけ!
ちゃんと理解しようとせず、難しそうなもんだと決め付けていました。。
もちろん応用していこうと思うと、ここから複雑になってはいきますが苦手意識が少しでもなくなればなと思います!
- 投稿日:2020-07-06T20:59:04+09:00
SwiftUI で Animatable なシェイプを作ってみる
シェイプにアニメーションをつけてみました
SwiftUI の勉強をかねて、以前の記事で作成した SwiftUI のシェイプにアニメーションをつけてみました。
— takehito-koshimizu (@takehitokoshim1) July 6, 2020 — takehito-koshimizu (@takehitokoshim1) July 6, 2020環境
- Xcode Version 11.5 (11E608c)
参考記事
今回はこちらの記事を参考にして、以前作成したシェイプにアニメーションをつけてみました。
動画が非常にわかりやすくて参考になりました。Animating simple shapes with animatableData - a free Hacking with iOS: SwiftUI Edition tutorial
https://www.hackingwithswift.com/books/ios-swiftui/animating-simple-shapes-with-animatabledataAnimating complex shapes with AnimatablePair - a free Hacking with iOS: SwiftUI Edition tutorial
https://www.hackingwithswift.com/books/ios-swiftui/animating-complex-shapes-with-animatablepair
Shape
はもともとAnimatable
まずは Xcode で
Shape
の定義にジャンプしてみます。@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) public protocol Shape : Animatable, View { /// Describes this shape as a path within a rectangular frame of reference. /// /// - Parameter rect: The frame of reference for describing this shape. /// - Returns: A path that describes this shape. func path(in rect: CGRect) -> Path }実は
Shape
はもともとAnimatable
のサブタイプであることがわかります。
更に、Animatable
にジャンプしてみます。/// A type that can be animated @available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *) public protocol Animatable { /// The type defining the data to be animated. associatedtype AnimatableData : VectorArithmetic /// The data to be animated. var animatableData: Self.AnimatableData { get set } }
Animatable
はanimatableData
という1つのプロパティを持ち、
これを実装することで、アニメーションをつけることができみたいです。自作のシェイプにアニメーションをつける
以前の記事で作成した
StarShape
のプロパティsmoothness
にアニメーションをつけてみます。import SwiftUI struct StarShape: Shape { var vertex: UInt = 5 var smoothness: Double = 0.5 var rotation: CGFloat = -.pi/2 var animatableData: Double { get { smoothness } set { smoothness = newValue } } func path(in rect: CGRect) -> Path { Path { path in let points: [CGPoint] = StarParameters(vertex: vertex, smoothness: smoothness) .center(x: rect.midX, y: rect.midY) .radius(min(rect.midX, rect.midY)) .rotated(by: rotation) .cgPoints path.move(to: points.first!) points.forEach { point in path.addLine(to: point) } path.closeSubpath() } } }ポイントはここ。
var animatableData: Double { get { smoothness } set { smoothness = newValue } }
animatableData
のget
,set
でプロパティを指定します。
たったこれだけです。早速、このシェイプを使ってみます。
import SwiftUI private let style = LinearGradient( gradient: Gradient(colors: [ Color(red: 239/255, green: 120.0/255, blue: 221/255), Color(red: 239/255, green: 172.0/255, blue: 120/255) ]), startPoint: UnitPoint(x: 0.5, y: 0), endPoint: UnitPoint(x: 0.5, y: 0.6) ) struct StarView: View { /// 頂点の数 var vertex: UInt = 5 /// 滑らかさ @State var smoothness: Double = 0.5 var body: some View { let shape = StarShape(vertex: vertex, smoothness: smoothness) return ZStack { shape.fill(style) shape.stroke(Color.black, lineWidth: 4) } .aspectRatio(1.1, contentMode: .fit) .onTapGesture { withAnimation { self.smoothness = Double(Int(self.smoothness * 10 + 1) % 5 + 5)/10 } } } }ビューのタップジェスチャーで
smoothness
を書換えて、再描画します。
アニメーションさせたいので、withAnimation
関数で囲んでいます。複数のプロパティにアニメーションをつける
StarShape
のプロパティvertex
もアニメーション可能にしてみます。
VectorArithmetic
に適合しているAnimatablePair
を利用します。import SwiftUI struct StarShape: Shape { var vertex: UInt = 5 var smoothness: Double = 0.5 var rotation: CGFloat = -.pi/2 var animatableData: AnimatablePair<Double, Double> { get { AnimatablePair(Double(vertex), smoothness) } set { vertex = UInt(newValue.first) smoothness = newValue.second } } /* 以下省略 */ }このようなペアを返すことで
animatableData
に複数のプロパティを参照させることができます。
次のように、型パラメータにAnimatablePair
を指定することで、3つ以上プロパティをアニメーション可能にすることもできるみたいです。// 3つ AnimatablePair<Double, AnimatablePair<Double, Double>> // 4つ AnimatablePair<Double, AnimatablePair<Double, AnimatablePair<Double, Double>>> // 5つ AnimatablePair<Double, AnimatablePair<Double, AnimatablePair<Double, AnimatablePair<Double, Double>>>>このシェイプを使って、頂点の増減にもアニメーションをかけてみます。
サンプルコードは次の通りです。import SwiftUI private let style = LinearGradient( gradient: Gradient(colors: [ Color(red: 239/255, green: 120.0/255, blue: 221/255), Color(red: 239/255, green: 172.0/255, blue: 120/255) ]), startPoint: UnitPoint(x: 0.5, y: 0), endPoint: UnitPoint(x: 0.5, y: 0.6) ) struct StarView: View { /// 頂点の数 @State var vertex: UInt = 5 /// 滑らかさ @State var smoothness: Double = 0.5 var body: some View { let shape = StarShape(vertex: vertex, smoothness: smoothness) return ZStack { shape.fill(style) shape.stroke(Color.black, lineWidth: 4) } .aspectRatio(1.1, contentMode: .fit) .onTapGesture { withAnimation { self.vertex = ((self.vertex + 3) % 5) + 5 self.smoothness = Double(Int(self.smoothness * 10 + 1) % 5 + 5)/10 } } } }まとめ
単純な図形であれば、簡単にアニメーションの実装ができることがわかりました。
- アニメーション可能なビューにするためには
Animatable
に適合するShape
はもともとAnimatable
AnimatablePair
で複数の属性をアニメーション可能にすることができる
- 投稿日:2020-07-06T15:38:42+09:00
TCAのテストコードについて解説
『Swiftによるアプリ開発のためのComposable Architectureがすごく良いので紹介したい』で紹介したThe Composable Architecture(TCA)にはテスト用の複数の型もあり、CaseStudiesにサンプルコードのテストが書かれているのでそれについてざっくり書いておきます。
TCAのCaseStudiesでテストしていること
- Reducerの処理をテストし、Stateが意図通り変更されたかを期待値と比べて検証する
- Reducerの処理が意図通り書かれているか
- Stateの構造が意図通りか
テスト用の型
- TestStore
- 役割
- Reducerをテストするための型
- テストしたいReducerをセットしたりテスト用のスケジューラなどに差し替える
- 期待値との比較を行えるメソッドを持つ
- メソッド
- send
- アクションを指定
- do
- スケジューラを進めたり。アサーション間で行われる作業を書く。
- receive
- sendでさらに実行されて受け取ったReducerのアクション
- TestScheduler
- 役割
- 実時間ではなくテスト用の時間で検証する
- 実時間だと細かな時間を気にしないといけないし
- 実際にその時間通りに動くようにする場合、長い時間のテストならその分だけ時間がかかる
- テスト用の時間とは仮想的な単位
- そもそもEffectがPublisherなので時系列にアクセスする必要がありスケジューラが必要
前提としてReducerはアクションが起こると、Stateを書き換えたりEffectを起こしたり、再度アクションを呼び出したりする。
TestStoreのsendメソッドはReducerに送るアクションで、Reducerがその後アクションをするならreceiveを書くようにし、Stateの状態を検証する。
実際のテストコード
02-Effects-Basics.swift
- これなに?
- カウンタとAPIアクセス
- なにがわかる?
- TestStoreの基本
import ComposableArchitecture import XCTest @testable import SwiftUICaseStudies class EffectsBasicsTests: XCTestCase { let scheduler = DispatchQueue.testScheduler func testCountDown() { // storeがテスト用storeのデータを用意する。 // テスト用の依存物を注入し、依存関係を解決できる。 let store = TestStore( initialState: EffectsBasicsState(), // Stateも本番用のStateをのまま reducer: effectsBasicsReducer, // Reducerも本番用 environment: EffectsBasicsEnvironment( // Environmentはテスト用 mainQueue: self.scheduler.eraseToAnyScheduler(), // メインキュー numberFact: { _ in fatalError("Unimplemented") } // Effectは実行したらエラー ) ) // 全体の流れについて検証できる。各ステップでは状態が予想通りに変化したことを証明する。 store.assert( // アクションを呼び出す .send(.incrementButtonTapped) { // Stateが期待値通りか検証 $0.count = 1 }, .send(.decrementButtonTapped) { $0.count = 0 }, // このサンプルの .decrementButtonTappedはなぜか デクリメントした後にインクリメントする。 // 時間を進めてその結果を受け取ろう。 .do { self.scheduler.advance(by: 1) }, スケジューラの1ステップ先までを取得 .receive(.incrementButtonTapped) { $0.count = 1 } ) } func testNumberFact() { let store = TestStore( initialState: EffectsBasicsState(), reducer: effectsBasicsReducer, environment: EffectsBasicsEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler(), numberFact: { n in Effect(value: "\(n) is a good number Brent") } ) ) store.assert( .send(.incrementButtonTapped) { $0.count = 1 }, // ファクトボタンをタップすると、エフェクトからの応答が返ってくることをテスト。 .send(.numberFactButtonTapped) { $0.isNumberFactRequestInFlight = true }, // Reducer内部でreceive(on: environment.mainQueue)してるのでスケジューラの時間をすすめる .do { self.scheduler.advance() }, // .success("1 is a good number Brent") はテストデータ .receive(.numberFactResponse(.success("1 is a good number Brent"))) { $0.isNumberFactRequestInFlight = false $0.numberFact = "1 is a good number Brent" // 要求する期待値 } ) } }期待値はクロージャでキャプチャさせるが、それと検証するテストデータはreceiveを使って渡す。
02-Effects-TimersTests
- これなに?
- 1秒ごとのタイマーのテスト
- なにがわかる?
- スケジューラについてちょっと分かる。
import ComposableArchitecture import XCTest @testable import SwiftUICaseStudies class TimersTests: XCTestCase { let scheduler = DispatchQueue.testScheduler func testStart() { let store = TestStore( initialState: TimersState(), reducer: timersReducer, environment: TimersEnvironment( mainQueue: self.scheduler.eraseToAnyScheduler() ) ) store.assert( // アクションを送る .send(.toggleTimerButtonTapped) { // Stateが書き換わるかどうか検証 $0.isTimerActive = true }, .do { self.scheduler.advance(by: 1) }, .receive(.timerTicked) { $0.secondsElapsed = 1 }, .do { self.scheduler.advance(by: 5) }, // 5ステップまでの取得 .receive(.timerTicked) { $0.secondsElapsed = 2 }, .receive(.timerTicked) { $0.secondsElapsed = 3 }, .receive(.timerTicked) { $0.secondsElapsed = 4 }, .receive(.timerTicked) { $0.secondsElapsed = 5 }, .receive(.timerTicked) { $0.secondsElapsed = 6 }, .send(.toggleTimerButtonTapped) { $0.isTimerActive = false } ) } }02-Effects-LongLivingTests
イベントとして、
UIApplication.userDidTakeScreenshotNotification
のNotificationを取得する場合。外部からのイベントをSubjectとして呼び出されるようにする。import Combine import ComposableArchitecture import XCTest @testable import SwiftUICaseStudies class LongLivingEffectsTests: XCTestCase { func testReducer() { // A passthrough subject to simulate the screenshot notification let screenshotTaken = PassthroughSubject<Void, Never>() let store = TestStore( initialState: .init(), reducer: longLivingEffectsReducer, environment: .init( userDidTakeScreenshot: Effect(screenshotTaken) ) ) store.assert( .send(.onAppear), // Simulate a screenshot being taken .do { screenshotTaken.send() }, .receive(.userDidTakeScreenshotNotification) { $0.screenshotCount = 1 }, .send(.onDisappear), // Simulate a screenshot being taken to show not effects // are executed. .do { screenshotTaken.send() } ) } }02-Effects-CancellationTests
Effectが動作していないことを確認する
func testTrivia_CancelButtonCancelsRequest() throws { let store = TestStore( initialState: .init(), reducer: effectsCancellationReducer, environment: .init( mainQueue: self.scheduler.eraseToAnyScheduler(), trivia: { n in Effect(value: "\(n) is a good number Brent") } ) ) // キャンセルされて \(n) is a good number Brent を取得しなければ成功 store.assert( .send(.triviaButtonTapped) { $0.isTriviaRequestInFlight = true }, .send(.cancelButtonTapped) { $0.isTriviaRequestInFlight = false }, .do { self.scheduler.run() } ) }
- 投稿日:2020-07-06T14:48:32+09:00
Flutterでパスワードロック・生体認証ロック機能を実装する方法
7月にFlutter開発を初めてから2作目のメモアプリ「IdeaShuffleMemo」をリリースしました。
■AppStore
https://apps.apple.com/jp/app/id1517535550■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/その中でパスワードロックの機能を実装したのですが、今回はそのやり方を少しだけ紹介していきたいと思います。
パスワードロック
注意事項
リファクタリングもせず、試行錯誤した状態なので、コードが冗長的な所もありますのでご了承ください。
今回実装する機能
- メニューで、パスワードと生体認証によるロック画面の実装
※2020/6 時点の情報を元に作成をしています。
知っておいたほうがいい知識
- アプリのライフサイクル パスワードロックでは、アプリのライフサイクルを知っておく必要があります。 ライフサイクルとは、簡単に言えばアプリの状態の流れのことです。アプリを閉じている状態、アプリを開いた状態など、アプリの状態には一連の流れがあるんです。 それでパスワードロック機能を実装するには、アプリが閉じた状態、開いた状態を感知して、適切な処理を走らせる必要があります。
ライフサイクルは、下記の記事がとても分かりやすく説明してくれているので、一読してみてください。素晴らしい記事をありがとうございます。
Flutterでアプリの復帰やサスペンドを検出して処理を実行する大まかな流れ
パスワードの設定
- パスワード設定のオンオフの記録
- パスワードの保存
パスワードロック画面の表示
- パスワード設定のオンオフをチェック。
- オンの場合、アプリが閉じた、もしくは開いた状態を感知して、パスワードロック画面を表示させる処理を走らせる。 オフの場合は、何もしない。
- パスワードロックもしくは生体認証を検証して合致したら、パスワードロック表示画面を閉じる
必要なパッケージ
passcode_screen
passcode_screen | Flutter Package
パスワードロック画面が簡単に作れるパッケージshared_preferences
shared_preferences | Flutter Package
データベースではなく、設定情報などが消えずに保存できるパッケージlocal_auth
local_auth | Flutter Package
生体認証を使うためのパッケージ。
生体認証がOKならTrueを返して、NGだったらFalseで返してくる。
難しそうに感じますが、結構シンプルな感じで、ありがたいパッケージです。作るページ
- パスワードオン・オフ設定画面
- パスワード設定入力画面
- パスワード設定入力確認画面
- パスワードロック画面
作り方によっては、まとめられるかしれませんが、そっちのほうが分かりやすいので、バラバラに作ってます。
この記事の前提
この記事では、基本的にstaful Widget内での実装なので、共通する部分は省いています。
class Example extends StatefulWidget { @override _ExampleState createState() => _ExampleState(); } class _ExampleState extends State<Example> { //この中に書いてあることが中心です。それ以外を書く場合には、分かりやすいようにその前後も記載するようにしています。 }各パッケージなどはimportされているものとしています。
適宜、各パッケージのReadmeをチェックください。わかりづらいところがあれば、コメントを気軽にしてください。
お答えできる部分があれば、お答えします。構築の流れ
パスワードオン・オフ設定
この辺りは特に難しいことではありませんが、パスワードロックと生体認証のオンオフのスイッチを実装します。
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("パスワードセッティング"), ), body: passwordSetWidget(),//パスワードのオンオフのスイッチを定義したメソットを呼び出し。 bottomNavigationBar: Footer(selectedIndex: 4,),//フッターを共通化しているので呼び出し。 ); } passwordSetWidget() { return SingleChildScrollView( child: Column( children: <Widget>[ Row( children: <Widget>[//設定のタイトル Padding( padding: const EdgeInsets.all(12.0), child: Text("設定", style: TextStyle(fontSize: 14.0),), ), ], ), Card(//パスワードロックのスイッチ child: SwitchListTile( value: _password, onChanged: _setPassword,//オンになった時の処理 activeTrackColor: Colors.blue, title: Row( children: <Widget>[ Padding( padding: const EdgeInsets.only(right: 10.0), child: Icon(Icons.lock), ), Expanded(child: Text("パスワードロック")), ], ), ), ), Card(//生体認証のスイッチ child: SwitchListTile( value: _facePass, onChanged: _setFacePass,//オンになった時の処理 activeTrackColor: Colors.blue, title: Row( children: <Widget>[ Padding( padding: const EdgeInsets.only(right: 10.0), child: Icon(Icons.face), ), Expanded(child: Text("生体認証")), ], ), ), ), ], ), ); }パスワードロックのスイッチ
パスワードロックがオンになったら、オンオフの状態を記録します。
最終的にパスワード1回目と2回目が合致したら、最終的にオンの状態を維持することになります。
この時、「パスワード忘れると、解除できなくなります。忘れないようにご注意ください!!」みたいな警告を出してあげたほうが優しいと思います。今回のサンプルではわかりづらくなるので、入れてません。bool _password; @override void initState() { // TODO: implement initState _getPasswordSetting(); super.initState(); } //パスワードのオンオフを記録するメソッド //shared_preferencesパッケージの機能を使って、設定情報などを端末に保存します。 _isPasswordLock(bool value) async { //受け取った引数true or falseをisPasswordLockという名前で保存してね!ってことにあります。 var prefs = await SharedPreferences.getInstance(); await prefs.setBool("isPasswordLock", value); } //パスワードのオンオフ設定を読み出すメソッド _getPasswordSetting() async { //これをinitstateで読み出しておいて、初期値としてセットしておきます。 var prefs = await SharedPreferences.getInstance(); _password = await prefs.getBool('isPasswordLock') ?? false; //??falseを忘れると、何も設定されていない初期状態の時にエラーになります。 //nullだったら、falseを入れなさいってことになります。 setState(() { _password; }); } //パスワード設定のスイッチが変更された時の処理 _setPassword(bool value) async{ await _isPasswordLock(value);//引数のValueをパスワードの設定状態として保存 if(value == true) { //このタイミングで一度ダイアログを表示したりして、パスワードを忘れると解除できませんのような文言を表示してもいいかもしれません。 Navigator.push( context, MaterialPageRoute( builder: (context) => PasswordSetting()));//パスワード設定画面の表示 } else { await _isFacePass(false);//パスワードがオフになったら生体認証も強制的にオフ Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PasswordSetScreen()));//スイッチを切り替えた状態にするために再読み込み } }生体認証のスイッチ
生体認証がオンになったら、生体認証が起動。
生体認証がOKなら、オンにします。
ただし、パスワードロックが設定されていない場合には、オンにできない使用にします。
生体認証がうまくいかない場合に備えてです。
ちなみに公式ドキュメントで、かならずiOSとAndroidのネイティブ側の設定で、生体認証を使えるように設定しておきましょう。生体認証はこの記事を参考にさせていただきました。
(https://qiita.com/coka__01/items/76af4ea73a6a8c8fa135)IOSの設定でこれを追記
Info.plist<key>NSFaceIDUsageDescription</key> <string>Why is my app authenticating using face id?</string>Android設定でこれを追記
AndroidManifest.xml<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app">//これすでにあるやつ //これを追記するだけ。 <uses-permission android:name="android.permission.USE_FINGERPRINT"/> <manifest>//これすでにあるやつ生体認証の処理
bool _facePass; @override void initState() { _getPasswordSetting();//さっき追加してます。 _getFacePassSetting(); super.initState(); } //生体認証のオンオフを保存するメソッド _isFacePass(bool value) async { var prefs = await SharedPreferences.getInstance(); await prefs.setBool("isFacePass", value); } //生体認証のオンオフ設定を読み出すメソッド _getFacePassSetting() async { //パスワードと同じくinitstateで読み出しておきましょう。 var prefs = await SharedPreferences.getInstance(); _facePass = await prefs.getBool('isFacePass') ?? false; //パスワードと同様に?? falseをしないと、初期状態でエラーになります。 setState(() { _facePass; }); } //生体認証のスイッチを変えた時の処理 _setFacePass(bool value) async{ await _getPasswordSetting(); if (_password == false && value == true ) { //パスワード設定がオフで、生体認証がオンだった場合は、生体認証がオンにできないように警告を表示する設定をしておきましょう。 return; } if (_password == true && value == true ) { //パスワード設定がオンで、生体認証がオンになった場合 var check = _authenticate(); //生体認証を発動 if(check == true) { //生体認証が承認されたら await _isFacePass(value);//生体認証設定状態をオンとして保存 return;// } } await _isFacePass(value);//生体認証をオフにされたら、オフの状態で保存 Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PasswordSetScreen())); //オフにするために画面を再読み込み。 } //生体認証のタイプをチェック Future<List<BiometricType>> _getAvailableBiometricTypes() async { List<BiometricType> availableBiometricTypes; try { availableBiometricTypes = await _localAuth.getAvailableBiometrics(); } on PlatformException catch (e) { //エラーの処理 } return availableBiometricTypes; //生体認証はこれやーと返す } //生体認証を呼び出して、結果を返す処理 Future<bool> _authenticate() async { bool result = false; List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes(); //生体認証が指紋か顔かチェック try { if (availableBiometricTypes.contains(BiometricType.face) || availableBiometricTypes.contains(BiometricType.fingerprint)) { result = await _localAuth.authenticateWithBiometrics(localizedReason: "生体認証"); } } on PlatformException catch (e) { //エラーの処理 } return result;//承認した結果をtrue or falseで返す。 }パスワードの設定画面
パスワードがオンになったら、まず最初にパスワードを入力してもらう画面を表示します。
final StreamController<bool> _verificationNotifier = StreamController<bool>.broadcast(); int passwordDigits = 4; //パスワードの桁数 @override void dispose() { // TODO: implement dispose _verificationNotifier.close(); super.dispose(); } @override Widget build(BuildContext context) { //パスワードロック画面を発動 return _showLockScreen( context, opaque: false,/ ); } //パスワードロック画面の見た目の詳細 //ボタンの大きさや、色などカスタマイズできます。 //各色の設定はmain.dartで定義しているカラーなどを設定しています。 _showLockScreen(BuildContext context, {bool opaque, List<String> digits}) { return PasscodeScreen( title: Column( children: <Widget>[ Icon(Icons.lock, size: 30), Text("パスワードを入力してください", textAlign: TextAlign.center, style: TextStyle(fontSize: 15), ), ], ), passwordDigits: passwordDigits, circleUIConfig: CircleUIConfig( borderColor: Theme.of(context).dividerColor, fillColor: Theme.of(context).dividerColor, circleSize: 20, ), keyboardUIConfig: KeyboardUIConfig( primaryColor: Theme.of(context).dividerColor, digitTextStyle: TextStyle(fontSize: 25), deleteButtonTextStyle: TextStyle(fontSize: 15), digitSize: 75, ), passwordEnteredCallback: _onPasscodeEntered,//パスワードが入力された時の処理 deleteButton: Icon(Icons.backspace, size: 15.0), cancelButton: Icon(Icons.cancel, size: 15.0), cancelCallback: _onPasscodeCancelled,//パスワードが入力がキャンセルされた時の処理 shouldTriggerVerification: _verificationNotifier.stream, backgroundColor: Theme.of(context).primaryColor, digits: digits, ); } //パスワードが桁数まで入力された発動する処理 _onPasscodeEntered(String enteredPasscode) { //パスワード確認フォームに飛ばす Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => ConfirmPassword(password: enteredPasscode))); //入力されているパスワードを引数として渡す。 } //入力がキャンセルされたら発動する処理 _onPasscodeCancelled() async{ await _isPasswordLock(false); //とりあえず、パスワード設定をオフにしておく Navigator.maybePop(context); Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PasswordSetScreen())); //パスワード設定を再読み込み } //パスワード設定のオンオフを記録する(shared_preferencesパッケージの機能です) _isPasswordLock(bool value) async { var prefs = await SharedPreferences.getInstance(); await prefs.setBool("isPasswordLock", value); }パスワードの入力が確認できたら、パスワード入力確認ページで飛ばします。
パスワードの確認をして、合致すればパスワードを保存します。
この時、キャンセルされば全ての設定を破棄します。//クラスをまたぐので、クラスまで一応書いておきますね。 class ConfirmPassword extends StatefulWidget { //これから処理するにあたって、パスワードの引数は必須ですよという設定をしておきます。だってパスワードない状態でこのページ開けてしまうのは困るので。 String password = ""; ConfirmPassword({@required this.password});//ぜってぇーパスワードを引数をとして送ってこいよなと言ってます。 @override _ConfirmPasswordState createState() => _ConfirmPasswordState(); } class _ConfirmPasswordState extends State<ConfirmPassword> { final StreamController<bool> _verificationNotifier = StreamController<bool>.broadcast();//入力状況を感知 bool isAuthenticated = false; int passwordDigits = 4; //パスワードの桁数 String passLockMessage; //エラーメッセージ //入力されたパスワードを保存する処理です。 _setPassword(String value) async { var prefs = await SharedPreferences.getInstance(); await prefs.setString("lockPassword", value); } @override void didChangeDependencies() { // TODO: implement didChangeDependencies super.didChangeDependencies(); passLockMessage = "念のためパスワードを入力してください"; //機能実装都合でここに書いてありますが、initstate内に書いても通常は問題ないはずです。 } @override void dispose() { // TODO: implement dispose _verificationNotifier.close(); super.dispose(); } @override Widget build(BuildContext context) { return _showLockScreen(//パスワード設定画面をは発動します。 context, opaque: false, ); } //再入力画面の設定 //ほぼほぼ前の方と同じです。 _showLockScreen(BuildContext context, {bool opaque, List<String> digits}) { print(widget.password); return PasscodeScreen( title: Column( children: <Widget>[ Icon(Icons.lock, size: 30), Text( "パスワード", textAlign: TextAlign.center, style: TextStyle(fontSize: 15), ), Text( '$passLockMessage',//メッセージを表示します。 textAlign: TextAlign.center, style: TextStyle(fontSize: 10), ), ], ), passwordDigits: passwordDigits, circleUIConfig: CircleUIConfig( borderColor: Theme.of(context).dividerColor, fillColor: Theme.of(context).dividerColor, circleSize: 20, ), keyboardUIConfig: KeyboardUIConfig( primaryColor: Theme.of(context).dividerColor, digitTextStyle: TextStyle(fontSize: 25), deleteButtonTextStyle: TextStyle(fontSize: 15), digitSize: 75, ), passwordEnteredCallback: _onPasscodeEntered, deleteButton: Icon(Icons.backspace, size: 15.0), cancelButton: Icon(Icons.cancel, size: 15.0), cancelCallback: _onPasscodeCancelled, shouldTriggerVerification: _verificationNotifier.stream, backgroundColor: Theme.of(context).primaryColor, digits: digits, ); } //パスワードが桁数入力されたときの処理 _onPasscodeEntered(String enteredPasscode) async { //パスワードのチェック bool isValid = widget.password == enteredPasscode; _verificationNotifier.add(isValid);//パスコードが正しいかどうかをパスコード画面に通知してます。 if (isValid) { await _setPassword(enteredPasscode);//パスワードを保存 //忘れガチですが、パスワード設定のオンとオフ設定、スイッチをオンにした直後に設定しています。 setState(() { passLockMessage = "";//メッセージを空に this.isAuthenticated = isValid; //trueにしています。 // }); } else { setState(() { passLockMessage = "パスワードが一致しません"; //エラーメッセージを変数に格納 }); } } //パスワードを再入力をキャンセルした場合 _onPasscodeCancelled() async{ await _isPasswordLock(false); //パスワードロック設定をオフ状態 Navigator.maybePop(context); Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PasswordSetScreen())); //パスワード設定画面に戻します。 } //パスワードロック設定のオンオフを記録 _isPasswordLock(bool value) async { var prefs = await SharedPreferences.getInstance(); await prefs.setBool("isPasswordLock", value); } }どこで処理を走らせるか
パスワードは設定できましたが、一つ考えるべきところが、どこでパスワード を表示される処理を走らせるか問題です。
後ほど、スマホの状態を感知して、処理を走らせるわけですが、すべてのページにその実装をするのは辛い。今回作ったアプリでは、共通ページとして、フッター専用のページを作って、それぞれのページで参照しています。
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("タイトル"), ), body: passwordSetWidget(), bottomNavigationBar: Footer(), }このフッター部分の状態を見て、パスワードロックの処理を走らせます。
パスワード機能を
スマホの状態で処理を発動
状態の管理として、4つに分類されます。今回はpuouseの時に処理を走らせるようにします。
というのも、それ以外の処理では、他の機能でページを読み込まれた時にも、パスワードロック画面が発動してしまうからです。//状態を管理するには、Classにwith WidgetsBindingObserverが必要。 class _Footer extends State <Footer> with WidgetsBindingObserver{ bool _isPasswordLock = false; //パスワード設定のオンオフを読み出し _getPasswordSetting() async { var prefs = await SharedPreferences.getInstance(); _isPasswordLock = await prefs.getBool('isPasswordLock'); setState(() { }); } void initState() { // TODO: implement initState super.initState(); WidgetsBinding.instance.addObserver(this);//スマホの状態を感知する WidgetsBinding.instance.addPostFrameCallback((_) async{ await _getPasswordSetting(); });//widgetの構築が終わったら、発動させる処理。 } @override void dispose() { // TODO: implement dispose WidgetsBinding.instance.removeObserver(this);//スマホの状態を感知終了 super.dispose(); } //スマホ状態によって処理を走らせます。 void didChangeAppLifecycleState(AppLifecycleState state) { // TODO: implement didChangeAppLifecycleState // super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.inactive) { //アプリの画面が非アクティブな時。 } else if (state == AppLifecycleState.paused) { _passWordLock();//アプリが完全にバックグランドになったら、パスワードロック画面を表示を発動。ただし表示されるのは、画面が再び立ち上がったあと。 } else if (state == AppLifecycleState.resumed) { //アプリが復帰した時の処理。これでもいいが、他の処理で何度も呼び出されることがあり、変なところでパスワードロックがかかってしまった。 } } }パスワードロックを表示
スマホのアプリがバックグランドになったら、パスワードロックを表示させます。
//状態を管理するには、Classにwith WidgetsBindingObserverが必要。2重でパスワードロック画面が表示されないように、ここでも状態管理をしています。 class _PassWordScreenState extends State<PassWordScreen> with WidgetsBindingObserver { final StreamController<bool> _verificationNotifier = StreamController<bool>.broadcast();//入力状態をチェック bool isAuthenticated = false; //パスワード入力が正しいか String passLockMessage = "";//エラーメッセージ int passwordDigits = 4;//入力する桁数 bool passwordView = false;//パスワード画面が表示されているか? bool _facePass = false; //FacePassの設定 String _password = ""; //パスワード LocalAuthentication _localAuth = LocalAuthentication(); //生体認証のインスタンス //パスワードの読み出し _getPassword() async { var prefs = await SharedPreferences.getInstance(); _password = await prefs.getString("lockPassword"); } //生体認証の読み出し _getFacePassSetting() async { var prefs = await SharedPreferences.getInstance(); _facePass = prefs.getBool('isFacePass'); setState(() { }); } //生体認証の種類の確認 Future<List<BiometricType>> _getAvailableBiometricTypes() async { List<BiometricType> availableBiometricTypes; try { availableBiometricTypes = await _localAuth.getAvailableBiometrics(); } on PlatformException catch (e) { // エラーを入力 } return availableBiometricTypes; } //生体認証をチェックしてOKだったらパスワードを処理を飛ばす。 Future<bool> _authenticate() async { bool result = false; List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes(); try { if (availableBiometricTypes.contains(BiometricType.face) || availableBiometricTypes.contains(BiometricType.fingerprint)) { result = await _localAuth.authenticateWithBiometrics(localizedReason: 生体認証); } } on PlatformException catch (e) { // エラーを入力 } if (result == true) { return _onPasscodeEntered(_password);//生体認証がOKだったら、ロックを解除するために、パスワード付きで解除するメソッドを発動。 } } @override void initState() { // TODO: implement initState super.initState(); WidgetsBinding.instance.addObserver(this);//スマホの状態をチェック _getPassword(); //パスワードを読み出し _getFacePassSetting(); //生体認証の読み出し } @override void didChangeDependencies() async { super.didChangeDependencies(); //生体認証を少しだけ遅らせます。そうしないと、パスワードロック画面が表示しきる前に発動してしまい、パスワードロック画面の表示が中途半端にになってしまいます。 await Future.delayed(Duration(milliseconds: 700)); if(_facePass != false) { await _authenticate(); } } @override void dispose() { // TODO: implement dispose _verificationNotifier.close();//入力状態確認の終了 WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.addObserver(this);//スマホの状態確認の終了 super.dispose(); } //スマホの状態確認 void didChangeAppLifecycleState(AppLifecycleState state) { // TODO: implement didChangeAppLifecycleState super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.inactive) { } else if (state == AppLifecycleState.paused) { //パスワードロック画面が表示されているので、この状態でスマホ閉じて開くと、パスワードロック画面が2重の状態になります。なのでパスワードロック画面が開いている状態では、画面を入れ替えるようにします。 Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PassWordScreen())); } else if (state == AppLifecycleState.resumed) { } } @override Widget build(BuildContext context) { return WillPopScope( child: _showLockScreen( context, opaque: false, ), //アンドロイドの場合、これを入れないと、戻るボタンでパスワードロック画面が閉じてしまいます。 onWillPop:() async => false); } //パスワード設定画面とほぼ一緒 _showLockScreen(BuildContext context, {bool opaque, List<String> digits}) { return PasscodeScreen( title: Column( children: <Widget>[ Icon(Icons.lock, size: 30), Text( 'Enter App Passcode', textAlign: TextAlign.center, style: TextStyle(fontSize: 15), ), Text( '$passLockMessage', textAlign: TextAlign.center, style: TextStyle(fontSize: 10), ), ], ), passwordDigits: passwordDigits, circleUIConfig: CircleUIConfig( borderColor: Theme.of(context).dividerColor, fillColor: Theme.of(context).dividerColor, circleSize: 20, ), keyboardUIConfig: KeyboardUIConfig( primaryColor: Theme.of(context).dividerColor, digitTextStyle: TextStyle(fontSize: 25), deleteButtonTextStyle: TextStyle(fontSize: 15), digitSize: 75, ), passwordEnteredCallback: _onPasscodeEntered, deleteButton: Icon(Icons.backspace, size: 15.0), shouldTriggerVerification: _verificationNotifier.stream, backgroundColor: Theme.of(context).primaryColor, digits: digits, ); } _onPasscodeEntered(String enteredPasscode) { bool isValid = _password == enteredPasscode; _verificationNotifier.add(isValid); if (isValid) { setState(() { passLockMessage = ""; this.isAuthenticated = isValid;//パスワードが合致した状態にんすr。 }); Navigator.pop(context); } else { setState(() { passLockMessage = "パスワードが違います。再入力してください。"; }); } } }ハマりポイント
解決できなかった部分が、パスワードロック画面の表示のタイミングです。
本来であれば、アプリを閉じて、スマホを立ち上げた段階で、パスワードロック画面が表示されている状態が好ましいです。
しかし、Flutterの仕組み上、画面推移の処理が走るのが、画面が立ち上がったあとなので、少しだけ画面が見えてします。ネイティブ側で処理をすれば、解決できるそうですが、ネイティブはさわれないので、今回は、問題なしと判断して、この部分は詰めませんでした。
まとめ
試行錯誤して作成したため、まだコードなどがうまくリファクタリングできていません。かなり冗長的なコードになっているので、時間を見つけて処理していきたいと思います。
完成の状態を見たい方は、ぜひアプリをダウンロードしてみてください。■AppStore
https://apps.apple.com/jp/app/id1517535550■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=jaそれは、また。
次は、GoogleDriveのバックアップ処理について書いていきたいと思います。
- 投稿日:2020-07-06T14:48:32+09:00
Flutterメモアプリでパスワードロック・生体認証ロック機能をす方法
7月にFlutter開発を始めてから2作目のメモアプリ「IdeaShuffleMemo」をリリースしました。
■AppStore
https://apps.apple.com/jp/app/id1517535550■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/その中でパスワードロックの機能を実装したのですが、今回はそのやり方を少しだけ紹介していきたいと思います。
■別の記事
【Flutter】GoogleDriveへのバックアップ・リストア機能を実装するまでの道のり
https://qiita.com/YuKiO-OO/items/67b471e6be6c4c4c26e9【Flutter】メモアプリ開発で使ったオススメのパッケージを紹介
https://qiita.com/YuKiO-OO/items/283f44da64d304a6228eパスワードロック
注意事項
リファクタリングもせず、試行錯誤した状態なので、コードが冗長的な所もありますのでご了承ください。
今回実装する機能
- メニューで、パスワードと生体認証によるロック画面の実装
※2020/6 時点の情報を元に作成をしています。
知っておいたほうがいい知識
- アプリのライフサイクル パスワードロックでは、アプリのライフサイクルを知っておく必要があります。 ライフサイクルとは、簡単に言えばアプリの状態の流れのことです。アプリを閉じている状態、アプリを開いた状態など、アプリの状態には一連の流れがあるんです。 それでパスワードロック機能を実装するには、アプリが閉じた状態、開いた状態を感知して、適切な処理を走らせる必要があります。
ライフサイクルは、下記の記事がとても分かりやすく説明してくれているので、一読してみてください。素晴らしい記事をありがとうございます。
Flutterでアプリの復帰やサスペンドを検出して処理を実行する大まかな流れ
パスワードの設定
- パスワード設定のオンオフの記録
- パスワードの保存
パスワードロック画面の表示
- パスワード設定のオンオフをチェック。
- オンの場合、アプリが閉じた、もしくは開いた状態を感知して、パスワードロック画面を表示させる処理を走らせる。 オフの場合は、何もしない。
- パスワードロックもしくは生体認証を検証して合致したら、パスワードロック表示画面を閉じる
必要なパッケージ
passcode_screen
passcode_screen | Flutter Package
パスワードロック画面が簡単に作れるパッケージshared_preferences
shared_preferences | Flutter Package
データベースではなく、設定情報などが消えずに保存できるパッケージlocal_auth
local_auth | Flutter Package
生体認証を使うためのパッケージ。
生体認証がOKならTrueを返して、NGだったらFalseで返してくる。
難しそうに感じますが、結構シンプルな感じで、ありがたいパッケージです。作るページ
- パスワードオン・オフ設定画面
- パスワード設定入力画面
- パスワード設定入力確認画面
- パスワードロック画面
作り方によっては、まとめられるかしれませんが、そっちのほうが分かりやすいので、バラバラに作ってます。
この記事の前提
この記事では、基本的にstaful Widget内での実装なので、共通する部分は省いています。
class Example extends StatefulWidget { @override _ExampleState createState() => _ExampleState(); } class _ExampleState extends State<Example> { //この中に書いてあることが中心です。それ以外を書く場合には、分かりやすいようにその前後も記載するようにしています。 }各パッケージなどはimportされているものとしています。
適宜、各パッケージのReadmeをチェックください。わかりづらいところがあれば、コメントを気軽にしてください。
お答えできる部分があれば、お答えします。構築の流れ
パスワードオン・オフ設定
この辺りは特に難しいことではありませんが、パスワードロックと生体認証のオンオフのスイッチを実装します。
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("パスワードセッティング"), ), body: passwordSetWidget(),//パスワードのオンオフのスイッチを定義したメソットを呼び出し。 bottomNavigationBar: Footer(selectedIndex: 4,),//フッターを共通化しているので呼び出し。 ); } passwordSetWidget() { return SingleChildScrollView( child: Column( children: <Widget>[ Row( children: <Widget>[//設定のタイトル Padding( padding: const EdgeInsets.all(12.0), child: Text("設定", style: TextStyle(fontSize: 14.0),), ), ], ), Card(//パスワードロックのスイッチ child: SwitchListTile( value: _password, onChanged: _setPassword,//オンになった時の処理 activeTrackColor: Colors.blue, title: Row( children: <Widget>[ Padding( padding: const EdgeInsets.only(right: 10.0), child: Icon(Icons.lock), ), Expanded(child: Text("パスワードロック")), ], ), ), ), Card(//生体認証のスイッチ child: SwitchListTile( value: _facePass, onChanged: _setFacePass,//オンになった時の処理 activeTrackColor: Colors.blue, title: Row( children: <Widget>[ Padding( padding: const EdgeInsets.only(right: 10.0), child: Icon(Icons.face), ), Expanded(child: Text("生体認証")), ], ), ), ), ], ), ); }パスワードロックのスイッチ
パスワードロックがオンになったら、オンオフの状態を記録します。
最終的にパスワード1回目と2回目が合致したら、最終的にオンの状態を維持することになります。
この時、「パスワード忘れると、解除できなくなります。忘れないようにご注意ください!!」みたいな警告を出してあげたほうが優しいと思います。今回のサンプルではわかりづらくなるので、入れてません。bool _password; @override void initState() { // TODO: implement initState _getPasswordSetting(); super.initState(); } //パスワードのオンオフを記録するメソッド //shared_preferencesパッケージの機能を使って、設定情報などを端末に保存します。 _isPasswordLock(bool value) async { //受け取った引数true or falseをisPasswordLockという名前で保存してね!ってことにあります。 var prefs = await SharedPreferences.getInstance(); await prefs.setBool("isPasswordLock", value); } //パスワードのオンオフ設定を読み出すメソッド _getPasswordSetting() async { //これをinitstateで読み出しておいて、初期値としてセットしておきます。 var prefs = await SharedPreferences.getInstance(); _password = await prefs.getBool('isPasswordLock') ?? false; //??falseを忘れると、何も設定されていない初期状態の時にエラーになります。 //nullだったら、falseを入れなさいってことになります。 setState(() { _password; }); } //パスワード設定のスイッチが変更された時の処理 _setPassword(bool value) async{ await _isPasswordLock(value);//引数のValueをパスワードの設定状態として保存 if(value == true) { //このタイミングで一度ダイアログを表示したりして、パスワードを忘れると解除できませんのような文言を表示してもいいかもしれません。 Navigator.push( context, MaterialPageRoute( builder: (context) => PasswordSetting()));//パスワード設定画面の表示 } else { await _isFacePass(false);//パスワードがオフになったら生体認証も強制的にオフ Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PasswordSetScreen()));//スイッチを切り替えた状態にするために再読み込み } }生体認証のスイッチ
生体認証がオンになったら、生体認証が起動。
生体認証がOKなら、オンにします。
ただし、パスワードロックが設定されていない場合には、オンにできない使用にします。
生体認証がうまくいかない場合に備えてです。
ちなみに公式ドキュメントで、かならずiOSとAndroidのネイティブ側の設定で、生体認証を使えるように設定しておきましょう。生体認証はこの記事を参考にさせていただきました。
(https://qiita.com/coka__01/items/76af4ea73a6a8c8fa135)IOSの設定でこれを追記
Info.plist<key>NSFaceIDUsageDescription</key> <string>Why is my app authenticating using face id?</string>Android設定でこれを追記
AndroidManifest.xml<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.app">//これすでにあるやつ //これを追記するだけ。 <uses-permission android:name="android.permission.USE_FINGERPRINT"/> <manifest>//これすでにあるやつ生体認証の処理
bool _facePass; @override void initState() { _getPasswordSetting();//さっき追加してます。 _getFacePassSetting(); super.initState(); } //生体認証のオンオフを保存するメソッド _isFacePass(bool value) async { var prefs = await SharedPreferences.getInstance(); await prefs.setBool("isFacePass", value); } //生体認証のオンオフ設定を読み出すメソッド _getFacePassSetting() async { //パスワードと同じくinitstateで読み出しておきましょう。 var prefs = await SharedPreferences.getInstance(); _facePass = await prefs.getBool('isFacePass') ?? false; //パスワードと同様に?? falseをしないと、初期状態でエラーになります。 setState(() { _facePass; }); } //生体認証のスイッチを変えた時の処理 _setFacePass(bool value) async{ await _getPasswordSetting(); if (_password == false && value == true ) { //パスワード設定がオフで、生体認証がオンだった場合は、生体認証がオンにできないように警告を表示する設定をしておきましょう。 return; } if (_password == true && value == true ) { //パスワード設定がオンで、生体認証がオンになった場合 var check = _authenticate(); //生体認証を発動 if(check == true) { //生体認証が承認されたら await _isFacePass(value);//生体認証設定状態をオンとして保存 return;// } } await _isFacePass(value);//生体認証をオフにされたら、オフの状態で保存 Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PasswordSetScreen())); //オフにするために画面を再読み込み。 } //生体認証のタイプをチェック Future<List<BiometricType>> _getAvailableBiometricTypes() async { List<BiometricType> availableBiometricTypes; try { availableBiometricTypes = await _localAuth.getAvailableBiometrics(); } on PlatformException catch (e) { //エラーの処理 } return availableBiometricTypes; //生体認証はこれやーと返す } //生体認証を呼び出して、結果を返す処理 Future<bool> _authenticate() async { bool result = false; List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes(); //生体認証が指紋か顔かチェック try { if (availableBiometricTypes.contains(BiometricType.face) || availableBiometricTypes.contains(BiometricType.fingerprint)) { result = await _localAuth.authenticateWithBiometrics(localizedReason: "生体認証"); } } on PlatformException catch (e) { //エラーの処理 } return result;//承認した結果をtrue or falseで返す。 }パスワードの設定画面
パスワードがオンになったら、まず最初にパスワードを入力してもらう画面を表示します。
final StreamController<bool> _verificationNotifier = StreamController<bool>.broadcast(); int passwordDigits = 4; //パスワードの桁数 @override void dispose() { // TODO: implement dispose _verificationNotifier.close(); super.dispose(); } @override Widget build(BuildContext context) { //パスワードロック画面を発動 return _showLockScreen( context, opaque: false,/ ); } //パスワードロック画面の見た目の詳細 //ボタンの大きさや、色などカスタマイズできます。 //各色の設定はmain.dartで定義しているカラーなどを設定しています。 _showLockScreen(BuildContext context, {bool opaque, List<String> digits}) { return PasscodeScreen( title: Column( children: <Widget>[ Icon(Icons.lock, size: 30), Text("パスワードを入力してください", textAlign: TextAlign.center, style: TextStyle(fontSize: 15), ), ], ), passwordDigits: passwordDigits, circleUIConfig: CircleUIConfig( borderColor: Theme.of(context).dividerColor, fillColor: Theme.of(context).dividerColor, circleSize: 20, ), keyboardUIConfig: KeyboardUIConfig( primaryColor: Theme.of(context).dividerColor, digitTextStyle: TextStyle(fontSize: 25), deleteButtonTextStyle: TextStyle(fontSize: 15), digitSize: 75, ), passwordEnteredCallback: _onPasscodeEntered,//パスワードが入力された時の処理 deleteButton: Icon(Icons.backspace, size: 15.0), cancelButton: Icon(Icons.cancel, size: 15.0), cancelCallback: _onPasscodeCancelled,//パスワードが入力がキャンセルされた時の処理 shouldTriggerVerification: _verificationNotifier.stream, backgroundColor: Theme.of(context).primaryColor, digits: digits, ); } //パスワードが桁数まで入力された発動する処理 _onPasscodeEntered(String enteredPasscode) { //パスワード確認フォームに飛ばす Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => ConfirmPassword(password: enteredPasscode))); //入力されているパスワードを引数として渡す。 } //入力がキャンセルされたら発動する処理 _onPasscodeCancelled() async{ await _isPasswordLock(false); //とりあえず、パスワード設定をオフにしておく Navigator.maybePop(context); Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PasswordSetScreen())); //パスワード設定を再読み込み } //パスワード設定のオンオフを記録する(shared_preferencesパッケージの機能です) _isPasswordLock(bool value) async { var prefs = await SharedPreferences.getInstance(); await prefs.setBool("isPasswordLock", value); }パスワードの入力が確認できたら、パスワード入力確認ページで飛ばします。
パスワードの確認をして、合致すればパスワードを保存します。
この時、キャンセルされば全ての設定を破棄します。//クラスをまたぐので、クラスまで一応書いておきますね。 class ConfirmPassword extends StatefulWidget { //これから処理するにあたって、パスワードの引数は必須ですよという設定をしておきます。だってパスワードない状態でこのページ開けてしまうのは困るので。 String password = ""; ConfirmPassword({@required this.password});//ぜってぇーパスワードを引数をとして送ってこいよなと言ってます。 @override _ConfirmPasswordState createState() => _ConfirmPasswordState(); } class _ConfirmPasswordState extends State<ConfirmPassword> { final StreamController<bool> _verificationNotifier = StreamController<bool>.broadcast();//入力状況を感知 bool isAuthenticated = false; int passwordDigits = 4; //パスワードの桁数 String passLockMessage; //エラーメッセージ //入力されたパスワードを保存する処理です。 _setPassword(String value) async { var prefs = await SharedPreferences.getInstance(); await prefs.setString("lockPassword", value); } @override void didChangeDependencies() { // TODO: implement didChangeDependencies super.didChangeDependencies(); passLockMessage = "念のためパスワードを入力してください"; //機能実装都合でここに書いてありますが、initstate内に書いても通常は問題ないはずです。 } @override void dispose() { // TODO: implement dispose _verificationNotifier.close(); super.dispose(); } @override Widget build(BuildContext context) { return _showLockScreen(//パスワード設定画面をは発動します。 context, opaque: false, ); } //再入力画面の設定 //ほぼほぼ前の方と同じです。 _showLockScreen(BuildContext context, {bool opaque, List<String> digits}) { print(widget.password); return PasscodeScreen( title: Column( children: <Widget>[ Icon(Icons.lock, size: 30), Text( "パスワード", textAlign: TextAlign.center, style: TextStyle(fontSize: 15), ), Text( '$passLockMessage',//メッセージを表示します。 textAlign: TextAlign.center, style: TextStyle(fontSize: 10), ), ], ), passwordDigits: passwordDigits, circleUIConfig: CircleUIConfig( borderColor: Theme.of(context).dividerColor, fillColor: Theme.of(context).dividerColor, circleSize: 20, ), keyboardUIConfig: KeyboardUIConfig( primaryColor: Theme.of(context).dividerColor, digitTextStyle: TextStyle(fontSize: 25), deleteButtonTextStyle: TextStyle(fontSize: 15), digitSize: 75, ), passwordEnteredCallback: _onPasscodeEntered, deleteButton: Icon(Icons.backspace, size: 15.0), cancelButton: Icon(Icons.cancel, size: 15.0), cancelCallback: _onPasscodeCancelled, shouldTriggerVerification: _verificationNotifier.stream, backgroundColor: Theme.of(context).primaryColor, digits: digits, ); } //パスワードが桁数入力されたときの処理 _onPasscodeEntered(String enteredPasscode) async { //パスワードのチェック bool isValid = widget.password == enteredPasscode; _verificationNotifier.add(isValid);//パスコードが正しいかどうかをパスコード画面に通知してます。 if (isValid) { await _setPassword(enteredPasscode);//パスワードを保存 //忘れガチですが、パスワード設定のオンとオフ設定、スイッチをオンにした直後に設定しています。 setState(() { passLockMessage = "";//メッセージを空に this.isAuthenticated = isValid; //trueにしています。 // }); } else { setState(() { passLockMessage = "パスワードが一致しません"; //エラーメッセージを変数に格納 }); } } //パスワードを再入力をキャンセルした場合 _onPasscodeCancelled() async{ await _isPasswordLock(false); //パスワードロック設定をオフ状態 Navigator.maybePop(context); Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PasswordSetScreen())); //パスワード設定画面に戻します。 } //パスワードロック設定のオンオフを記録 _isPasswordLock(bool value) async { var prefs = await SharedPreferences.getInstance(); await prefs.setBool("isPasswordLock", value); } }どこで処理を走らせるか
パスワードは設定できましたが、一つ考えるべきところが、どこでパスワード を表示される処理を走らせるか問題です。
後ほど、スマホの状態を感知して、処理を走らせるわけですが、すべてのページにその実装をするのは辛い。今回作ったアプリでは、共通ページとして、フッター専用のページを作って、それぞれのページで参照しています。
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("タイトル"), ), body: passwordSetWidget(), bottomNavigationBar: Footer(), }このフッター部分の状態を見て、パスワードロックの処理を走らせます。
パスワード機能を
スマホの状態で処理を発動
状態の管理として、4つに分類されます。今回はpuouseの時に処理を走らせるようにします。
というのも、それ以外の処理では、他の機能でページを読み込まれた時にも、パスワードロック画面が発動してしまうからです。//状態を管理するには、Classにwith WidgetsBindingObserverが必要。 class _Footer extends State <Footer> with WidgetsBindingObserver{ bool _isPasswordLock = false; //パスワード設定のオンオフを読み出し _getPasswordSetting() async { var prefs = await SharedPreferences.getInstance(); _isPasswordLock = await prefs.getBool('isPasswordLock'); setState(() { }); } void initState() { // TODO: implement initState super.initState(); WidgetsBinding.instance.addObserver(this);//スマホの状態を感知する WidgetsBinding.instance.addPostFrameCallback((_) async{ await _getPasswordSetting(); });//widgetの構築が終わったら、発動させる処理。 } @override void dispose() { // TODO: implement dispose WidgetsBinding.instance.removeObserver(this);//スマホの状態を感知終了 super.dispose(); } //スマホ状態によって処理を走らせます。 void didChangeAppLifecycleState(AppLifecycleState state) { // TODO: implement didChangeAppLifecycleState // super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.inactive) { //アプリの画面が非アクティブな時。 } else if (state == AppLifecycleState.paused) { _passWordLock();//アプリが完全にバックグランドになったら、パスワードロック画面を表示を発動。ただし表示されるのは、画面が再び立ち上がったあと。 } else if (state == AppLifecycleState.resumed) { //アプリが復帰した時の処理。これでもいいが、他の処理で何度も呼び出されることがあり、変なところでパスワードロックがかかってしまった。 } } }パスワードロックを表示
スマホのアプリがバックグランドになったら、パスワードロックを表示させます。
//状態を管理するには、Classにwith WidgetsBindingObserverが必要。2重でパスワードロック画面が表示されないように、ここでも状態管理をしています。 class _PassWordScreenState extends State<PassWordScreen> with WidgetsBindingObserver { final StreamController<bool> _verificationNotifier = StreamController<bool>.broadcast();//入力状態をチェック bool isAuthenticated = false; //パスワード入力が正しいか String passLockMessage = "";//エラーメッセージ int passwordDigits = 4;//入力する桁数 bool passwordView = false;//パスワード画面が表示されているか? bool _facePass = false; //FacePassの設定 String _password = ""; //パスワード LocalAuthentication _localAuth = LocalAuthentication(); //生体認証のインスタンス //パスワードの読み出し _getPassword() async { var prefs = await SharedPreferences.getInstance(); _password = await prefs.getString("lockPassword"); } //生体認証の読み出し _getFacePassSetting() async { var prefs = await SharedPreferences.getInstance(); _facePass = prefs.getBool('isFacePass'); setState(() { }); } //生体認証の種類の確認 Future<List<BiometricType>> _getAvailableBiometricTypes() async { List<BiometricType> availableBiometricTypes; try { availableBiometricTypes = await _localAuth.getAvailableBiometrics(); } on PlatformException catch (e) { // エラーを入力 } return availableBiometricTypes; } //生体認証をチェックしてOKだったらパスワードを処理を飛ばす。 Future<bool> _authenticate() async { bool result = false; List<BiometricType> availableBiometricTypes = await _getAvailableBiometricTypes(); try { if (availableBiometricTypes.contains(BiometricType.face) || availableBiometricTypes.contains(BiometricType.fingerprint)) { result = await _localAuth.authenticateWithBiometrics(localizedReason: 生体認証); } } on PlatformException catch (e) { // エラーを入力 } if (result == true) { return _onPasscodeEntered(_password);//生体認証がOKだったら、ロックを解除するために、パスワード付きで解除するメソッドを発動。 } } @override void initState() { // TODO: implement initState super.initState(); WidgetsBinding.instance.addObserver(this);//スマホの状態をチェック _getPassword(); //パスワードを読み出し _getFacePassSetting(); //生体認証の読み出し } @override void didChangeDependencies() async { super.didChangeDependencies(); //生体認証を少しだけ遅らせます。そうしないと、パスワードロック画面が表示しきる前に発動してしまい、パスワードロック画面の表示が中途半端にになってしまいます。 await Future.delayed(Duration(milliseconds: 700)); if(_facePass != false) { await _authenticate(); } } @override void dispose() { // TODO: implement dispose _verificationNotifier.close();//入力状態確認の終了 WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.addObserver(this);//スマホの状態確認の終了 super.dispose(); } //スマホの状態確認 void didChangeAppLifecycleState(AppLifecycleState state) { // TODO: implement didChangeAppLifecycleState super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.inactive) { } else if (state == AppLifecycleState.paused) { //パスワードロック画面が表示されているので、この状態でスマホ閉じて開くと、パスワードロック画面が2重の状態になります。なのでパスワードロック画面が開いている状態では、画面を入れ替えるようにします。 Navigator.pushReplacement( context, MaterialPageRoute( builder: (context) => PassWordScreen())); } else if (state == AppLifecycleState.resumed) { } } @override Widget build(BuildContext context) { return WillPopScope( child: _showLockScreen( context, opaque: false, ), //アンドロイドの場合、これを入れないと、戻るボタンでパスワードロック画面が閉じてしまいます。 onWillPop:() async => false); } //パスワード設定画面とほぼ一緒 _showLockScreen(BuildContext context, {bool opaque, List<String> digits}) { return PasscodeScreen( title: Column( children: <Widget>[ Icon(Icons.lock, size: 30), Text( 'Enter App Passcode', textAlign: TextAlign.center, style: TextStyle(fontSize: 15), ), Text( '$passLockMessage', textAlign: TextAlign.center, style: TextStyle(fontSize: 10), ), ], ), passwordDigits: passwordDigits, circleUIConfig: CircleUIConfig( borderColor: Theme.of(context).dividerColor, fillColor: Theme.of(context).dividerColor, circleSize: 20, ), keyboardUIConfig: KeyboardUIConfig( primaryColor: Theme.of(context).dividerColor, digitTextStyle: TextStyle(fontSize: 25), deleteButtonTextStyle: TextStyle(fontSize: 15), digitSize: 75, ), passwordEnteredCallback: _onPasscodeEntered, deleteButton: Icon(Icons.backspace, size: 15.0), shouldTriggerVerification: _verificationNotifier.stream, backgroundColor: Theme.of(context).primaryColor, digits: digits, ); } _onPasscodeEntered(String enteredPasscode) { bool isValid = _password == enteredPasscode; _verificationNotifier.add(isValid); if (isValid) { setState(() { passLockMessage = ""; this.isAuthenticated = isValid;//パスワードが合致した状態にんすr。 }); Navigator.pop(context); } else { setState(() { passLockMessage = "パスワードが違います。再入力してください。"; }); } } }ハマりポイント
解決できなかった部分が、パスワードロック画面の表示のタイミングです。
本来であれば、アプリを閉じて、スマホを立ち上げた段階で、パスワードロック画面が表示されている状態が好ましいです。
しかし、Flutterの仕組み上、画面推移の処理が走るのが、画面が立ち上がったあとなので、少しだけ画面が見えてします。ネイティブ側で処理をすれば、解決できるそうですが、ネイティブはさわれないので、今回は、問題なしと判断して、この部分は詰めませんでした。
まとめ
試行錯誤して作成したため、まだコードなどがうまくリファクタリングできていません。かなり冗長的なコードになっているので、時間を見つけて処理していきたいと思います。
完成の状態を見たい方は、ぜひアプリをダウンロードしてみてください。あと、四苦八苦して作ったアプリも是非ともよろしくお願いします。アプリのアイデア出しに使ってみてください。
■AppStore
https://apps.apple.com/jp/app/id1517535550■Google Play
https://play.google.com/store/apps/details?id=com.IdeaShuffleMemoApp&hl=ja■アプリの詳細記事
https://yukio.site/idea_shuffle_memo/あとツイッターもやってますので、ぜひチェックください。
Webサービスやアプリ!
— YuKiO | 個人開発&Flutter (@oo_forward) July 2, 2020
斬新なアイデアをひらめきたい人に!
アイデアを発想するためのメモアプリ✍️
IdeaShuffleMemoをリリースしました!
■AppStorehttps://t.co/gEPzEEJ7mt
■Google Playhttps://t.co/w0vTiOanGE
使ってみてください?#駆け出しエンジニアと繋がりたい #プログラミング pic.twitter.com/gQ0dOMNgGB
それは、また。
次は、GoogleDriveのバックアップ処理について書いていきたいと思います。
- 投稿日:2020-07-06T09:51:59+09:00
[iOS 14] Widgetの技術的要点をざっくり
Widgetはミニアプリではない
- Widget上で操作することは想定されておらず、スクロールやインタラクティブなUI (e.g. Switch) や動画などはサポートされない
- ただし、Widgetをタップするとディープリンクでアプリ内の適切な画面を開ける
- Smallサイズは1つのリンクしか持てない
- MediumとLargeサイズは複数のリンクを持つことができ、タップした領域によって別のディープリンクを開くことができる
(Meet WidgetKit より)画面は事前に描画される
- 「Widgetを表示するタイミングでアプリを起動してロードして...」という読み込み時間がないのでWidgetの変更がすぐに反映される
- ただし、初回表示時や設定変更時 (e.g. ダイナミックタイプの変更) には画面描画まで時間が掛かるのでPlaceholderが表示される
- SwiftUIの宣言的にViewを定義できる性質を活かした仕組み
- Timelineという仕組みによって画面更新のスケジューリングを行う
Widgetのバリエーション
- 1つのアプリは複数種類のWidgetを持てる
- 1つのアプリの中で複数の WidgetKit extension を持つこともできるが、1つの WidgetKit extension に複数Widgetをまとめることが推奨されている
WidgetBundle
を使うことで1つの WidgetKit extension に複数のWidgetをまとめることができる- 1つのWidgetは複数のサイズを持てる
WidgetFamily
に定義されている以下3種類がある
- systemSmall
- systemMedium
- systemLarge
- どれか1つのサイズだけサポートしても良い
- 同じ種類・サイズのWidgetをホーム画面に複数個置ける
- Widgetの設定を変えることで別の見た目にすることが可能
Smart Stack
- 複数のWidgetを1つの枠にまとめられる機能
(Design great widgets より)- 上下スワイプでWidget間を移動できる
- システムがその時々で一番最適なWidgetを自動で判断して表示する
- 以下の情報をアプリから提供するとより精度が上がる
- Shortcuts donation
- アプリ内のユーザー操作をSiriに伝える
- 関連度 (Relevance)
TimelineEntry
に対してscore
とduration
を付与するscore
の最大値と最小値は自由に決めて良いパーソナライズ (Widget Configuration)
- ホーム画面を編集モードにしてWidgetをタップするとWidgetの設定ができる
(Design great widgets より)- Intents (i.e.
SiriKit
のINIntent
) を用いることで可能となる
- SiriやShortcutsとの連携でも使われている技術
- Intentsの情報から自動で設定画面のUIが作られる
- ネットワークを通じて設定項目を取得するなどして動的に設定項目を変えることもできる
各キーワードについてもう少し詳しく
Timeline
- いつ、どのようなViewを表示するかをあらかじめ計画する
- Timelineの更新タイミング
- システムによる更新
ReloadPolicy
からシステムが更新に最適なタイミングを判断する- システムによってよく見られているWidgetだと判斷された場合はシステムがより頻繁に更新する
- つまり
ReloadPolicy
通りに更新されるわけではない?- アプリ起因による更新
WidgetCenter
を通じて任意のタイミングで
- アプリ本体からでも WidgetKit extension からでも
URLSession
の background session が完了したタイミング
- データをインターネットから取得してきた後など
- 負荷を上げないために頻繁に更新しすぎないよう注意
- 例外として、
Text
viewのパラメーターにDate型を渡して日時を表示する場合は、意図的に更新しなくても随時最新の日時に更新される
- 詳しくは Keeping a Widget Up To Date の "Display Dynamic Dates" の項を参照のこと
Snapshot
- 現在の日時や状態などの情報が含まれる
TimelineEntry
を元にしてそれに対応する画面を返す- パラメーターで渡される
context.isPreview
がtrueのときは Widget Gallery で使用される
- Widget Gallery とは、ホーム画面にWidgetを追加する際に見本のWidgetが表示される画面
(Meet WidgetKit より)- 素早くユーザーに見本を提示する必要があるため、サーバーからデータを取得するWidgetの場合は代わりにサンプルデータを使う
Placeholder
- 初回表示時や設定変更時 (e.g. ダイナミックタイプの変更) に、まだTimelineが作成されていないときに表示される
- WidgetのViewに
.isPlaceholder(true)
を追加するだけで簡単に作成できる
SwiftUI gives you .isPlaceholder(true) in WidgetKit, which makes it incredibly simple to generate a placeholder Widget view ✨ pic.twitter.com/40Fw8TzfWE
— Jordan Singer (@jsngr) June 25, 2020参考資料
- https://developer.apple.com/videos/play/wwdc2020/10028/
- https://developer.apple.com/videos/play/wwdc2020/10103/
- https://developer.apple.com/videos/play/wwdc2020/10034/
- https://developer.apple.com/videos/play/wwdc2020/10035/
- https://developer.apple.com/videos/play/wwdc2020/10036/
- https://developer.apple.com/documentation/widgetkit
- https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension
- https://developer.apple.com/documentation/widgetkit/making-a-configurable-widget
- https://developer.apple.com/documentation/widgetkit/keeping-a-widget-up-to-date