SwiftUIとFlutterで同じアプリを作ってみました

2022.05.20

2022.05.23

Fabeee社員ブログ

SwiftUIとFlutterで同じアプリを作ってみました

どうも、むらです。
昨年の夏頃に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が自動で更新される

 

  • 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もまだ実務で使ったことがないので、実務で使うときはもっと色々使えるようにしたいです。

ソースコード

ソースコードを同じフォルダに添付しました。
指摘などあればご連絡いただけると幸いです。

Fabeee編集部

Fabeee編集部

こちらの記事はFabeee編集部が執筆しております。