Closed8

isowords で TCA の使い方を学ぶ -ハーフモーダルみたいなやつの出し方-

アイカワアイカワ

isowords では ↓ のようなモーダルがあるので、その出し方をコードを見ながら探ってみる

(View の実現方法だけ気になる方は一番下の BottomMenuWrapper だけを見れば TCA あるなしに関係なく今回のようなモーダルを実現するコードがわかって良さそう)

アイカワアイカワ

上の NavigationBar っぽいものの三点リーダーをタップするとこのモーダルが開かれるので、その View のコードを見てみる

該当コードは GameNavView.swift

GameNavView.swift
struct GameNavView: View {
  let store: Store<GameState, GameAction>
  @ObservedObject var viewStore: ViewStore<ViewState, GameAction>
  // ...

  public init(
    store: Store<GameState, GameAction>
  ) {
    self.store = store
    self.viewStore = ViewStore(self.store.scope(state: ViewState.init(state:)))
  }

  var body: some View {
    HStack(alignment: .center, spacing: 8) {
      Button(action: { self.viewStore.send(.trayButtonTapped, animation: .default) }) {
      // ...
      }
      // ↓ これがおそらく三点リーダー部分のコード
      Button(action: { self.viewStore.send(.menuButtonTapped, animation: .default) }) {
        Image(systemName: "ellipsis")
          .foregroundColor(.adaptiveBlack)
          .adaptivePadding()
          .rotationEffect(.degrees(90))
      }
      .frame(maxHeight: .infinity)
      .background(
        Color.adaptiveBlack
          .opacity(0.05)
      )
      .cornerRadius(12)
    }
    .fixedSize(horizontal: false, vertical: true)
    .padding([.leading, .trailing])
    .adaptivePadding([.top, .bottom], 8)
  }
}

GameActionmenuButtonTapped という Action を .default animation 付きで送っているっぽい

GameActionGameCore.swift で定義されている

GameCore.swift
public enum GameAction: Equatable {
  // ...
  case menuButtonTapped
  // ...
}

送られた Action をもとにしている Reducer の処理は以下のような感じ

GameCore.swift
public func gameReducer<StatePath, Action, Environment>(
  state: StatePath,
  action: CasePath<Action, GameAction>,
  environment: @escaping (Environment) -> GameEnvironment,
  isHapticsEnabled: @escaping (StatePath.Root) -> Bool
) -> Reducer<StatePath.Root, Action, Environment>
where StatePath: ComposableArchitecture.Path, StatePath.Value == GameState {
  return Reducer.combine(
  // ...

    .init { state, action, environment in
      switch action {
      // ...

      case .menuButtonTapped:
        state.bottomMenu = .gameMenu(state: state)
        return .none

GameState で定義されている、

GameCore.swift
public struct GameState: Equatable {
  // ...
  public var bottomMenu: BottomMenuState<GameAction>?
  // ...
}

BottomMenuState<GameAction>? 型の bottomMenu.gameMenu(state: state) をセットしているっぽい

アイカワアイカワ

.gameMenu(state: ) は以下のように GameCore.swift で定義されている

GameCore.swift
extension BottomMenuState where Action == GameAction {
  // ...
    static func gameMenu(state: GameState) -> Self {
    var menu = BottomMenuState(title: menuTitle(state: state))
    menu.onDismiss = .init(action: .dismissBottomMenu, animation: .default)

    if state.isResumable {
      menu.buttons.append(
        .init(
          title: .init("Main menu"),
          icon: .exit,
          action: .init(action: .exitButtonTapped, animation: .default)
        )
      )
    }
    if state.turnBasedContext != nil {
      menu.buttons.append(
        .init(
          title: .init("Forfeit"),
          icon: .flag,
          action: .init(action: .forfeitGameButtonTapped, animation: .default)
        )
      )
    } else {
      menu.buttons.append(
        .init(
          title: .init("End game"),
          icon: .flag,
          action: .init(action: .endGameButtonTapped, animation: .default)
        )
      )
    }
    menu.footerButton = .init(
      title: .init("Settings"),
      icon: Image(systemName: "gear"),
      action: .init(action: .settingsButtonTapped, animation: .default)
    )
    return menu
  }
}

state に応じて、BottomeMenuState 型の menu という変数を変更し、最終的にその menu を返却している

アイカワアイカワ

BottomMenuState を理解するために BottomMenuState の定義を見てみると以下のようになっている

ComposableBottomMenu.swift
public struct BottomMenuState<Action> {
  public var buttons: [Button]
  public var footerButton: Button?
  public var message: TextState?
  public var onDismiss: MenuAction?
  public var title: TextState
  // モーダルのタイトル部分以外は特に指定せずイニシャライズ可能になっている
  // `gameMenu(state: )` ではこのイニシャライザを利用している
  public init(
    title: TextState,
    message: TextState? = nil,
    buttons: [Button] = [],
    footerButton: Button? = nil,
    onDismiss: MenuAction? = nil
  ) {
    self.buttons = buttons
    self.footerButton = footerButton
    self.message = message
    self.onDismiss = onDismiss
    self.title = title
  }

  public struct Button {
    public let action: MenuAction?
    public let icon: Image
    public let title: TextState
    // `gameMenu(state: )` では、このイニシャライザを利用して、ボタンを条件式に応じて追加している
    public init(
      title: TextState,
      icon: Image,
      // MenuAction は `gameMenu(state: )` でいうと `GameAction` が型である `action` を持っている
      action: MenuAction? = nil
    ) {
      self.action = action
      self.icon = icon
      self.title = title
    }
  }

  public struct MenuAction {
    public let action: Action
    fileprivate let animation: Animation

    fileprivate enum Animation: CustomDebugOutputConvertible, Equatable {
      case inherited
      case explicit(SwiftUI.Animation?)

      var debugOutput: String {
        switch self {

        case .inherited:
          return "<inherited>"
        case let .explicit(animation):
          return String(describing: animation)
        }
      }
    }
    // イニシャライザで `animation` を指定すれば explicit(明示的な)animation として定義される
    public init(
      action: Action,
      animation: SwiftUI.Animation?
    ) {
      self.action = action
      self.animation = .explicit(animation)
    }
    // `animation` を指定しないこともできる
    public init(
      action: Action
    ) {
      self.action = action
      self.animation = .inherited
    }
  }
}
// extension で Equtable に準拠させるようにしている
extension BottomMenuState: Equatable where Action: Equatable {}
extension BottomMenuState.Button: Equatable where Action: Equatable {}
extension BottomMenuState.MenuAction: Equatable where Action: Equatable {}

ここまででモーダルが出るまでに menuButtonTapped という Action が送信されて、GameState が持っている bottomMenu という State にモーダル View の情報を持った BottomMenuState<GameAction>? が代入されるところまで理解することができた

アイカワアイカワ

次は bottomMenu に値が入った時、どのようにしてモーダルが表示されるかについて探っていく

GameNavViewGameView の子 View であり、実際にモーダルを表示するための function が付与されている部分は GameView にある(以下は抜粋)

public struct GameView<Content>: View where Content: View {
  // ...
  let store: Store<GameState, GameAction>
  // ...

  public var body: some View {
    GeometryReader { proxy in
      ZStack {
      // ...
      }
      .background(
        Color(self.colorScheme == .dark ? .hex(0x111111) : .white)
          .ignoresSafeArea()
      )
      .bottomMenu(self.store.scope(state: \.bottomMenu))
      .alert(self.store.scope(state: \.alert, action: GameAction.alert))
    }
    .onAppear { self.viewStore.send(.onAppear) }
  }
}

どうやら、TCA で Alert を扱う時と同じように storebottomMenu の KeyPath で scope してあげれば、bottomMenu の State が変更された時に自動的にモーダルが表示される仕組みっぽい(これだけ柔軟に利用できる形になっているので、いずれ TCA でこのモーダルを表示できる仕組みが Alert と同じように実現されそうな気配も感じる...)

アイカワアイカワ

では次は実際に .bottomMenu という function がどこで定義されているかを見ていく
これは ComposableBottomMenu.swift に以下のように定義されている

ComposableBottomMenu.swift
extension View {
  public func bottomMenu<Action>(
    _ store: Store<BottomMenuState<Action>?, Action>
  ) -> some View where Action: Equatable {
    WithViewStore(store) { viewStore in
      self.bottomMenu(
        item: Binding(
          get: {
            viewStore.state?.converted(send: viewStore.send, sendWithAnimation: viewStore.send)
          },
          set: { state, transaction in
            withAnimation(transaction.disablesAnimations ? nil : transaction.animation) {
              if state == nil, let onDismiss = viewStore.state?.onDismiss {
                switch onDismiss.animation {
                case .inherited:
                  viewStore.send(onDismiss.action)
                case let .explicit(animation):
                  viewStore.send(onDismiss.action, animation: animation)
                }
              }
            }
          }
        )
      )
    }
  }
}

extension BottomMenuState {
  // BottomMenuState を BottomMenu に変換してくれる fileprivate な function
  fileprivate func converted(
    send: @escaping (Action) -> Void,
    sendWithAnimation: @escaping (Action, Animation?) -> Void
  ) -> BottomMenu {
    .init(
      title: Text(self.title),
      message: self.message.map { Text($0) },
      buttons: self.buttons.map { $0.converted(send: send, sendWithAnimation: sendWithAnimation) },
      footerButton: self.footerButton.map {
        $0.converted(send: send, sendWithAnimation: sendWithAnimation)
      }
    )
  }
}

extension BottomMenuState.Button {
  // BottomMenuState.Button を BottomMenu.Button に変換してくれる fileprivate な function
  fileprivate func converted(
    send: @escaping (Action) -> Void,
    sendWithAnimation: @escaping (Action, Animation?) -> Void
  ) -> BottomMenu.Button {
    .init(
      title: Text(self.title),
      icon: self.icon,
      action: {
        if let action = self.action {
          switch action.animation {
          case .inherited:
            send(action.action)
          case let .explicit(animation):
            sendWithAnimation(action.action, animation)
          }
        }
      }
    )
  }
}

View の extension として表現されているので、.bottomMenu(self.store.scope(state: \.bottomMenu)) のように実装できていたようである

何やら色々行われているようではあるが、重要なのは一番最初の部分。 store を受け取って、WithViewStore を介して self.bottomMenuitem を Binding しているようである

アイカワアイカワ

では、self.bottomMenu つまり、View.bottomMenu がどこで定義されているかを探っていく

View.bottomMenu はどうやら BottomMenu.swift で定義されているようである

BottomMenu.swift
extension View {
  public func bottomMenu(
    item: Binding<BottomMenu?>
  ) -> some View {
    BottomMenuWrapper(content: self, item: item)
  }
}

どうやら BottomMenu 型の item を受け取って、BottomMenuWrapper というものを使って View を表示しているっぽい

BottomMenu 型は同じく BottomMenu.swift で定義されているが、BottomMenuState とあまり変わらないのでここには書かないことにする

とりあえず、BottomMenu 型の itembottomMenu(item: ) に渡す必要があるので、先ほど色々と BottomMenuStateBottomMenu に変更しようとしていた理由を理解することができた

アイカワアイカワ

では次に View の実体部分である BottomMenuWrapper を見てみる

同じくこれも BottomMenu.swift で定義されている

BottomMenu.swift
private struct BottomMenuWrapper<Content: View>: View {
  @Environment(\.colorScheme) var colorScheme
  @Environment(\.deviceState) var deviceState
  let content: Content
  @Binding var item: BottomMenu?

  var body: some View {
    ZStack(alignment: .bottom) {
      self.content
        .zIndex(0)

      ZStack(alignment: .bottom) {
        if let menu = self.item {
          Rectangle()
            .fill(Color.isowordsBlack.opacity(0.4))
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .onTapGesture { self.item = nil }
            .zIndex(1)
            .transition(.opacity)

          VStack(spacing: 24) {
            Group {
              HStack {
                menu.title
                  .adaptiveFont(.matterMedium, size: 18)
                Spacer()
                Button(action: { self.item = nil }) {
                  Image(systemName: "xmark.circle.fill")
                    .font(.system(size: 24))
                }
              }

              if let message = menu.message {
                message
                  .adaptiveFont(.matterMedium, size: 24)
              }
            }
            .foregroundColor(self.colorScheme == .light ? .white : .isowordsOrange)

            HStack(spacing: 24) {
              ForEach(menu.buttons) { button in
                MenuButton(
                  button:
                    button
                    .additionalAction { self.item = nil }
                )
              }
            }

            if let footerButton = menu.footerButton {
              Button(
                action: {
                  self.item = nil
                  footerButton.action()
                }
              ) {
                HStack {
                  footerButton.title
                    .adaptiveFont(.matterMedium, size: 18)
                  Spacer()
                  footerButton.icon
                }
              }
              .buttonStyle(
                ActionButtonStyle(
                  backgroundColor: self.colorScheme == .dark ? .isowordsOrange : .white,
                  foregroundColor: self.colorScheme == .dark ? .isowordsBlack : .isowordsOrange
                )
              )
            }
          }
          .frame(maxWidth: .infinity)
          .padding(24)
          .padding(.bottom)
          .background(self.colorScheme == .light ? Color.isowordsOrange : .hex(0x242424))
          .adaptiveCornerRadius([UIRectCorner.topLeft, .topRight], .grid(3))
          .zIndex(2)
          .transition(.move(edge: .bottom))
          .screenEdgePadding(self.deviceState.isPad ? .horizontal : [])
        }
      }
      .ignoresSafeArea()
    }
  }
}

SwiftUI に慣れ親しんだ人なら、もうほぼ理解できそうなコードだ

ZStack をうまく利用しながらモーダル View を実現している。このスクラップの最初に貼った画像と比較しながらコードを辿れば十分に理解することができた

このスクラップは2021/03/28にクローズされました