2022.05.20
2022.05.23
Fabeee社員ブログ
どうも、むらです。
昨年の夏頃にSwiftUIとFlutterで同じアプリを作ったので、そこで学んだことを書いてみました。
当時作ったのは動かない箇所が多かったので、公開できるように少し手直しをしました。
目次
アプリの概要
以下の遷移をします。
以下の機能を持たせました。
- ログインをするとユーザ一覧を表示します。
- ユーザ一覧のユーザを選択すると、ユーザ詳細に遷移します。
- ログイン画面からユーザ登録画面に遷移します。
- ログイン画面など、テキストフィールドのある画面はバリデーションを行い、エラーが出ている際はボタンが押せなくなります。
- APIはAPIモックのデータの参照ができます。データ更新のAPIは値を取得するのみでデータの更新はできません。
画面一覧
作成した画面です。
FlutterはCupertinoというios向けのウィジェットを用いてiOSに近いUIで実装しました。
SwiftUI | Flutter | |
---|---|---|
ログイン | ![]() |
![]() |
ユーザ一覧 | ![]() |
![]() |
ユーザ詳細 | ![]() |
![]() |
ユーザ登録 | ![]() |
![]() |
APIモック
My JSON Serverというサービスを使ってAPIのモックとして使いました。
レスポンスのデータのjsonを作成し、それをgithubにアップロードし、urlにアクセスすると、APIのモックとして利用可能です。
- My JSON Server
https://my-json-server.typicode.com/
- レスポンスのデータ
以下のURLからユーザ一覧を取得します。
リストの表示
SwiftUI
SwiftUIではデータバインディングを用いてリストの表示を行いました。(チュートリアルを参考にしています。)
以下のProperty wrapperを用いました。
@ObservedObject・・・インスタンスの変更を監視する。変更したらviewが自動で更新される
@Published・・・ @ObservedObjectで定義したインスタンスのプロパティに付ける
@State・・・プロパティの変更を監視する。変更したらviewが自動で更新される
@Published・・・ @ObservedObjectで定義したインスタンスのプロパティに付ける
@State・・・プロパティの変更を監視する。変更したらviewが自動で更新される
- View
struct UserListView: View { @ObservedObject var userListViewModel: UserListViewModel @State private var isShowLogin = true var body: some View { NavigationView { List(self.userListViewModel.userList, id: \.id) { user in let userViewModel = UserViewModel(user: user) NavigationLink(destination: UserView(userViewModel: userViewModel)) { UserListRow(user: user) } } .navigationTitle("user list") .navigationBarItems( leading: Button("logout", action: { isShowLogin.toggle() }) ) .fullScreenCover(isPresented: $isShowLogin, content: {LoginView()}) }.onAppear(perform: loadData) } func loadData() { guard let url = URL(string: "https://my-json-server.typicode.com/f-rm/SwiftUITraining/users") else { return } let reqest = URLRequest(url: url) URLSession.shared.dataTask(with: reqest) { data, response, error in if let data = data { if let response = try? JSONDecoder().decode(UserResponse.self, from: data) { DispatchQueue.main.async { self.userListViewModel.userList = response.data } } } }.resume() } }
- View Model
class UserListViewModel: ObservableObject { @Published var userList: [User] init(userList: [User]) { self.userList = userList } }
Flutter
FlutterではProviderを用いて、リクエストで取得したデータをデータ表示部のウィジェットに渡してデータを表示させるようにしました。
- View
Widget _cupertinoWidget(String title) { final List userList = Provider.of(context)._userList; // ユーザ一覧のデータを状態監視して取得 return CupertinoPageScaffold( child: CustomScrollView( semanticChildCount: userList.length, slivers: [ CupertinoSliverNavigationBar( leading: CupertinoButton( padding: EdgeInsets.zero, child: Text("logout"), onPressed: () { print("logout button pressed"); showLoginPage(); }), largeTitle: Text(title), ), SliverSafeArea( top: false, minimum: const EdgeInsets.only(top: 8), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) { if (index < userList.length) { return _listItem(userList[index]); // ユーザ一覧のデータをセルのウィジェットにセット } return null; }, ), ), ) ], )); }
- View Model
class TopViewModel with ChangeNotifier { List Widget _cupertinoWidget(String title) { final List userList = Provider.of(context)._userList; // ユーザ一覧のデータを状態監視して取得 return CupertinoPageScaffold( child: CustomScrollView( semanticChildCount: userList.length, slivers: [ CupertinoSliverNavigationBar( leading: CupertinoButton( padding: EdgeInsets.zero, child: Text("logout"), onPressed: () { print("logout button pressed"); showLoginPage(); }), largeTitle: Text(title), ), SliverSafeArea( top: false, minimum: const EdgeInsets.only(top: 8), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) { if (index < userList.length) { return _listItem(userList[index]); // ユーザ一覧のデータをセルのウィジェットにセット } return null; }, ), ), ) ], )); }
バリデーション
SwiftUIではリストの表示で使ったのと同じproperty wrapperを用いてプロパティの変更を監視し、
内容によってエラーメッセージを表示させるようにしてバリデーションを実装しました。
また、combineを使って、プロパティの内容からイベントのハンドリングを行って、
バリデーションの状態の制御を行いました。(RxSwiftのcombineLatestと同じことをしてます。)
SwiftUI
class LoginViewModel: ObservableObject {
@Published var email = "test@test.com"
@Published var password = "test1234"
@Published var isEmptyEmail = ""
@Published var isEmptyPassword = ""
@Published var invalidCharCountEmail = ""
@Published var invalidCharCountPassword = ""
@Published var invalidEmail = ""
@Published var isValidInput = false
var hasChangedEmail = false
var hasChangedPassword = false
init() {
let mailValidation = $email.map({ !$0.isEmpty && $0.isWithin50Charctor && $0.isValidEmail }).eraseToAnyPublisher()
let passValidation = $password.map({ !$0.isEmpty && $0.isOver8Charctor }).eraseToAnyPublisher()
// バリデーションの状態の制御
Publishers.CombineLatest(mailValidation, passValidation)
.map({ [$0.0, $0.1] })
.map({ $0.allSatisfy({ $0 })})
.assign(to: &$isValidInput)
$email.map({ !$0.isEmpty ? "" : "email is empty" }).assign(to: &$isEmptyEmail)
$email.map({ $0.isWithin50Charctor ? "" : "email must be within 50 characters" }).assign(to: &$invalidCharCountEmail)
$email.map({ $0.isValidEmail ? "" : "email is invalid" }).assign(to: &$invalidEmail)
$password.map({ !$0.isEmpty ? "" : "password is empty" }).assign(to: &$isEmptyPassword)
$password.map({ $0.isOver8Charctor && $0.isWithin50Charctor ? "" : "password must be 8-50 characters" }).assign(to: &$invalidCharCountPassword)
}
}
extension String {
var isValidEmail: Bool {
let emailRegEx = "[A-Z0-9a-z._+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,4}"
let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
return emailTest.evaluate(with: self)
}
var isOver8Charctor: Bool {
return self.count >= 8
}
var isWithin50Charctor: Bool {
return self.count <= 50
}
}
Flutter
Flutterでは標準のバリデーション用のライブラリがあるので、
そちらを用いてバリデーションを実装しましたが、後で色々修正をしました
https://docs.flutter.dev/cookbook/forms/validation
validator: (String? value) {
return _validate(fieldType, value);
},
String? _validate(LoginField fieldType, String? value) {
if (fieldType == LoginField.email) {
if (email.isEmpty) { return null; }
if (value == null || value.isEmpty) {
return 'email is empty';
}
if (value.length < 8) {
return 'email must be over 8 characters';
}
if (!RegExp(
r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$')
.hasMatch(value)) {
return 'email is invalid';
}
isValidField[LoginField.email] = true;
} else if (fieldType == LoginField.password) {
if (password.isEmpty) { return null; }
if (value == null || value.isEmpty) {
return 'password is empty';
}
if (value.length < 8) {
return 'password must be over 8 characters';
}
isValidField[LoginField.password] = true;
}
return null;
}
作ってみた感想
- FlutterはCupertinoで作ったので、比較的綺麗に作れたように感じました。
- 画面の作り方はSwiftUIもFlutterも似ていて、画面の移植は楽だったように感じました。
- バリデーターなど、SwiftUIかFlutterのどちらかだと簡単にできたのが、もう片方だと手間だったりする部分があって、
- 同じ動きにしようとすると苦労することが多々あったような気がします。
- とはいえ、手早くアプリを作るのであれば、Flutterの採用を検討しても問題ないように感じました。
- SwiftUIもFlutterもまだ実務で使ったことがないので、実務で使うときはもっと色々使えるようにしたいです。
ソースコード
ソースコードを同じフォルダに添付しました。
指摘などあればご連絡いただけると幸いです。