📱

TCA + Supabaseでログイン画面を作る

2023/09/12に公開

TCA (The Composable Architecture) とは?

アプリを開発するための状態管理フレームワーク。TCA は、開発者が使いやすいように、一貫性のある理解しやすい方法でアプリケーションを構築するために使用される。

https://github.com/pointfreeco/swift-composable-architecture

日本語版はこちら

主な機能

  • State management
    • 単純な値型を使ってアプリケーションの状態を管理し、多くの画面にわたって状態を共有する方法
  • Composition
    • 大規模なフィーチャーをどのように小さな構成要素に分解し、それぞれ独立したモジュールに抽出し、フィーチャーを形成するために簡単につなぎ合わせることができるか
  • Side effects
    • アプリケーションの特定の部分を、可能な限りテスト可能で理解しやすい方法で外界と対話させる方法
  • Testing
    • 結合テストからE2Eテストまで
  • Ergonomics
    • 上記のすべてを、できるだけ少ないコンセプトと可動部品で、シンプルなAPIで実現するにはどうすればいいか

パッケージ導入

今回は学習の為、外部パッケージ導入用のPackageを作成し、その Package.swift にTCAをインポートするようにしてみたいと思います。

  1. File > New > Package から DependenciesPackages という名前でPackageを作成する

    • ※ この時「Add to:」に自身のプロジェクトを指定する
  2. Package.swift を以下に修正する

    // swift-tools-version: 5.8
    // The swift-tools-version declares the minimum version of Swift required to build this package.
    
    import PackageDescription
    
    let package = Package(
        name: "DependenciesPackages",
        platforms: [
          .iOS(.v16)
        ],
        products: [
            .library(
                name: "DependenciesPackages",
                targets: ["DependenciesPackages"]),
        ],
        dependencies: [
            .package(url: "https://github.com/pointfreeco/swift-composable-architecture", branch: "main")
        ],
        targets: [
            .target(
                name: "DependenciesPackages",
                dependencies: [
                    .product(name: "ComposableArchitecture", package: "swift-composable-architecture")
                ]),
        ]
    )
    
  3. メインのTargetの設定 > General > Frameworks, Libraries, and Embedded Content に DependenciesPackages を追加する

カウンターアプリのサンプル

こちらを参考に作成してみたいと思います。

1. Reducerの作成

struct Counter: Reducer {
    struct State: Equatable {
        var count = 0
    }

    enum Action: Equatable {
        case decrement
        case increment
    }

    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .decrement:
            state.count -= 1
            return .none
        case .increment:
            state.count += 1
            return .none
        }
    }
}

2. Viewの修正

struct CounterView: View {
    let store: StoreOf<Counter>

    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            HStack {
                Button {
                    viewStore.send(.decrement)
                } label: {
                    Image(systemName: "minus")
                }
                Text("\(viewStore.count)")
                    .monospacedDigit()
                Button {
                    viewStore.send(.increment)
                } label: {
                    Image(systemName: "plus")
                }
            }
        }
    }
}

3. Previewの修正

struct CounterView_Previews: PreviewProvider {
    static var previews: some View {
        CounterView(
            store: Store(initialState: Counter.State()) {
              Counter()
            }
        )
    }
}

Previewで確認してみると、ちゃんとカウントアップ・ダウンできているかと思います。

image1.gif

TCA + Supabaseでログイン画面を作ってみる

まずはSupabaseのSwift用のライブラリを導入

Supabase Swift Client - Introduction

Package.swift に以下を追加

let package = Package(
    ...
    dependencies: [
        ...
        .package(url: "https://github.com/supabase/supabase-swift", branch: "master"), // Add the package
    ],
    targets: [
        .target(
            name: "YourTargetName",
            dependencies: [
              .product(name: "Supabase", package: "supabase-swift")
            ] // Add as a dependency
        )
    ]
)

試しにログインできるか↓で確認してみます。

import Supabase
import SwiftUI

struct LoginView: View {
    // swiftlint:disable line_length
    let client = SupabaseClient(
        supabaseURL: URL(string: "...")!, // supabase projectのurlを入力
        supabaseKey: "...") // // supabase projectのanon api kyを入力
    // swiftlint:enable line_length
    var body: some View {
        VStack {
            Button("LOGIN") {
                Task.detached(priority: .background) {
                    do {
                        try await client.auth.signIn(email: "...", password: "...")
                        let session = try await client.auth.session
                        print("### Session Info: \(session)")
                    } catch {
                        print("### Sign In Error: \(error)")
                    }
                }
            }
        }
    }
}

「LOGIN」ボタンを押してセッション情報がログに表示されていればOKです。

実際にっぽいログイン画面を作成

LoginButton

まずは、専用のLoginButtonのコンポーネントを作っていきたいと思います。↓のButtonStyleで調整していきます。

SwiftUIのButtonStyle

SwiftUIのButtonStyleをカスタマイズ |TAAT

ButtonStyle | Apple Developer Documentation

import SwiftUI

struct RoundedButtonStyle: ButtonStyle {
    @Environment(\.isEnabled)
    var isEnabled

    var color: Color = .blue
    private let disabledColor: Color = .init(uiColor: .lightGray)
    private let cornerRadius: CGFloat = 12.0

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .padding()
            .fontWeight(.bold)
            .foregroundColor(.white)
            // 有効無効でカラーを変更
            .background(isEnabled ? color : disabledColor)
            // 押下時かどうかで透明度を変更
            .opacity(configuration.isPressed ? 0.8 : 1.0)
            .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
    }
}

struct LoginButton: View {
    @Binding var isDisabled: Bool
    var onClick: () -> Void

    var body: some View {
        Button(action: onClick, label: {
            Text("Login")
        })
        .buttonStyle(RoundedButtonStyle())
        .disabled(isDisabled)
    }
}

struct LoginButton_Previews: PreviewProvider {
    static var previews: some View {
        ForEach(["iPhone 14", "iPhone SE (3rd generation)", "iPad (10th generation)"], id: \.self) { deviceName in
            VStack {
                LoginButton(isDisabled: .constant(true)) {
                    print("onClick")
                }
                LoginButton(isDisabled: .constant(false)) {
                    print("onClick")
                }
            }
            .previewDevice(PreviewDevice(rawValue: deviceName))
            .previewDisplayName(deviceName)
        }
    }
}

↓Preview表示した様子がこちらです。

image2.png

Reducerプロトコルを実装したSessionを作成する

Indicatorの表示/非表示の切り替えや、name, passwordのデータ、実際のログイン処理を行える様に実装していきます。

struct Session: Reducer {
    struct State: Equatable {
        var isLoading = false
        var name = ""
        var password = ""
    }

    enum Action: Equatable {
        case login
        case setName(String)
        case setPassword(String)
        case startLoading
        case stopLoading
    }

    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .login:
            state.isLoading = true
            return .run(priority: .background, operation: { [name = state.name, password = state.password] send in
                do {
                    try await client.auth.signIn(email: name, password: password)
                    let session = try await client.auth.session
                    print("### Session Info: \(session)")
                } catch {
                    print("### Sign Up Error: \(error)")
                }
                await send(.stopLoading) // ログイン処理が完了したら自身のActionを呼び出してます
            })
        case let .setName(arg):
            state.name = arg
            return .none
        case let .setPassword(arg):
            state.password = arg
            return .none
        case .startLoading:
            state.isLoading = true
            return .none
        case .stopLoading:
            state.isLoading = false
            return .none
        }
    }
}

最終的なログイン画面の実装


import Combine
import ComposableArchitecture
import Supabase
import SwiftUI

// swiftlint:disable line_length
let client = SupabaseClient(
    supabaseURL: URL(string: "...")!,
    supabaseKey: "...")
// swiftlint:enable line_length

struct Session: Reducer {
    struct State: Equatable {
        var isLoading = false
        var name = ""
        var password = ""
    }

    enum Action: Equatable {
        case login
        case setName(String)
        case setPassword(String)
        case startLoading
        case stopLoading
    }

    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .login:
            state.isLoading = true
            return .run(priority: .background, operation: { [name = state.name, password = state.password] send in
                do {
                    try await client.auth.signIn(email: name, password: password)
                    let session = try await client.auth.session
                    print("### Session Info: \(session)")
                } catch {
                    print("### Sign Up Error: \(error)")
                }
                await send(.stopLoading)
            })
        case let .setName(arg):
            state.name = arg
            return .none
        case let .setPassword(arg):
            state.password = arg
            return .none
        case .startLoading:
            state.isLoading = true
            return .none
        case .stopLoading:
            state.isLoading = false
            return .none
        }
    }
}
// UIActivityIndicatorViewを使ったシンプルなIndicatorです
struct ActivityIndicator: UIViewRepresentable {
    func makeUIView(context: UIViewRepresentableContext<ActivityIndicator>) -> UIActivityIndicatorView {
        return UIActivityIndicatorView(style: .large)
    }
    func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext<ActivityIndicator>) {
        uiView.startAnimating()
    }
}

struct LoginView: View {
    let store: StoreOf<Session>
    @State var isDisabled = true

    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            ZStack {
                VStack(alignment: .center) {
                    VStack(spacing: 24) {
                        TextField("User name", text: viewStore.binding(get: \.name, send: Session.Action.setName))
                            .padding()
                            .background(Color(.systemGray6))
                            .cornerRadius(12.0)
                            .onChange(of: viewStore.name) { _ in
                                isDisabled = viewStore.name.isEmpty || viewStore.password.isEmpty
                            }
                        SecureField("Password",
                                    text: viewStore.binding(get: \.password, send: Session.Action.setPassword))
                            .padding()
                            .background(Color(.systemGray6))
                            .cornerRadius(12.0)
                            .onChange(of: viewStore.password) { _ in
                                isDisabled = viewStore.name.isEmpty || viewStore.password.isEmpty
                            }
                    }
                        .padding(.horizontal, 24)
                        .padding(.bottom, 24)
                    LoginButton(isDisabled: $isDisabled) {
                        viewStore.send(.login)
                    }
                }
                if viewStore.isLoading {
                    ActivityIndicator()
                }
            }
        }
    }
}

struct LoginView_Previews: PreviewProvider {
    static var previews: some View {
        LoginView(
            store: Store(initialState: Session.State()) {
                Session()
            }
        )
    }
}

シュミレータで動作させてみたものが↓こちらになります。「Login」ボタン押下してコンソールログにSession情報が出力されていれば成功です!
image3.gif

参考URL

Discussion