isowords で TCA の使い方を学ぶ -ハーフモーダルみたいなやつの出し方-
isowords では ↓ のようなモーダルがあるので、その出し方をコードを見ながら探ってみる
(View の実現方法だけ気になる方は一番下の BottomMenuWrapper
だけを見れば TCA あるなしに関係なく今回のようなモーダルを実現するコードがわかって良さそう)
上の NavigationBar っぽいものの三点リーダーをタップするとこのモーダルが開かれるので、その View のコードを見てみる
該当コードは 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)
}
}
GameAction
の menuButtonTapped
という Action を .default
animation 付きで送っているっぽい
GameAction
は GameCore.swift
で定義されている
public enum GameAction: Equatable {
// ...
case menuButtonTapped
// ...
}
送られた Action をもとにしている Reducer の処理は以下のような感じ
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
で定義されている、
public struct GameState: Equatable {
// ...
public var bottomMenu: BottomMenuState<GameAction>?
// ...
}
BottomMenuState<GameAction>?
型の bottomMenu
に .gameMenu(state: state)
をセットしているっぽい
.gameMenu(state: )
は以下のように 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
の定義を見てみると以下のようになっている
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
に値が入った時、どのようにしてモーダルが表示されるかについて探っていく
GameNavView
は GameView
の子 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 を扱う時と同じように store
を bottomMenu
の KeyPath で scope してあげれば、bottomMenu
の State が変更された時に自動的にモーダルが表示される仕組みっぽい(これだけ柔軟に利用できる形になっているので、いずれ TCA でこのモーダルを表示できる仕組みが Alert と同じように実現されそうな気配も感じる...)
では次は実際に .bottomMenu
という function がどこで定義されているかを見ていく
これは 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.bottomMenu
に item
を Binding しているようである
では、self.bottomMenu
つまり、View.bottomMenu
がどこで定義されているかを探っていく
View.bottomMenu
はどうやら 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
型の item
を bottomMenu(item: )
に渡す必要があるので、先ほど色々と BottomMenuState
を BottomMenu
に変更しようとしていた理由を理解することができた
では次に View の実体部分である BottomMenuWrapper
を見てみる
同じくこれも 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 を実現している。このスクラップの最初に貼った画像と比較しながらコードを辿れば十分に理解することができた