Presenting a modal(sheet) with TCA in SwiftUI
I wrote article to setup TCA. Next, I started organizing screen navigations with TCA into an initial SwiftUI Project. In this post, I organized a modal sheet
with TCA and SwiftData.
Case Studies
I found case studies from TCA repository.
However I could only see source file when I clicked the link. Should I download and open project?
Documentation
Fortunately there is documentation.
I was surprised by the documentation’s perfection. It was designed like Apple’s official document.
Tutorials
I found tutorials section
and I was surprised again. Tutorial was also completely Apple’s. I can master TCA in 4 and half hours??
Sheet
But this time I wanted to focus only navigation.
Sheet
First tutorial is using .sheet
to present one screen. I modified it for SwiftUI initial project. In this article, I will use modified terms instead of original tutorial to practice the usage.
.sheet(
item: $store.scope(state: \.addDate, action: \.addDate)
) { addDateStore in
NavigationStack {
AddItemScreen(store: addDateStore)
}
}
Maybe you might already know about .sheet
. Then skip this explanation.
func sheet<Item, Content>(
item: Binding<Item?>
item
A binding to an optional source of truth for the sheet. When item
is non-nil
, the system passes the item’s content to the modifier’s closure.
It means new screen will be created with given item and If the item
is not nil, addDate screen will be presented.
What is scope
?? I will talk about it later.
Binding
item
should be bindable, therefore I attached @Bining
to state.
@Binding var store: StoreOf<MainStore>
It means we should inject store from out of View
WindowGroup {
ContentView(store: Store(initialState: MainStore.State()) {
MainStore()
})
}
or create store in the constructor
init(store: StoreOf<MainStore> = Store(initialState: MainStore.State()) {
MainStore()
}) {
_store = .constant(store)
}
I gave default store like this to implement unit tests later.
init(store: StoreOf<MainStore> = Store(initialState: MainStore.State()) {
MainStore()
}) {
_store = store
}
sending
I created another store for AddDateScreen.
@Reducer
struct AddDateStore {
@ObservableState
struct State: Equatable {
var selectedDate: Date = Date.now
}
enum Action {
case saveButtonTapped
case cancelButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .saveButtonTapped:
return .none
case .cancelButtonTapped:
return .none
}
}
}
}
And implemented AddDateScreen.
struct AddDateScreen: View {
@Binding var store: StoreOf<AddDateStore>
var body: some View {
let date = store.selectedDate
Form{
DatePicker(selection: $store.selectedDate.sending(\.selectDate)) {
Text("\(date)")
}
Button("Save") {
store.send(.saveButtonTapped)
}
}
}
}
However this emitted error like this.
Because the store’s states are read only. So I created an action one more.
struct AddDateStore {
...
enum Action {
...
case selectDate(date: Date)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
...
case .selectDate(let date):
state.selectedDate = date
return .none
}
}
}
}
TCA provides sending to make the bindable object using specific action. This means sending
let know DatePicker to call selectDate
if the selection is changed.
DatePicker(selection: $store.selectedDate.sending(\.selectDate)) {
Text("\(date)")
}
Preview can give binding object to the screen using .constant
.
#Preview {
AddDateScreen(store: .constant(Store(initialState: AddDateStore.State(), reducer: {
AddDateStore()
})))
}
Presents
So how can MainScreen
know if user tapped save button on AddDateScreen
?
One store’s state can contain sub store’s state using Presents
macro
struct MainStore {
@ObservableState
struct State: Equatable {
...
@Presents var addDateState: AddDateStore.State?
}
PresentationAction
Surprisingly action also can have sub actions using PresentationAction
.
enum Action {
...
case addDateAction(PresentationAction<AddDateStore.Action>)
}
But it is a little tricky, it’s not macro.
Can’t we give macro to case? I will deep dive into it later someday ..
ifLet
To present AddDateScreen, we need to create AddDateStore instance when tapped Add button.
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
...
case .addButtonTapped:
state.addDateState = AddDateStore.State.init(selectedDate: Date.now)
return .none
}
}
}
Now MainStore
can receive addDateAction
? Nope!
We need one more. We need to let know MainStore when to start receiving the sub actions.
.ifLet(\.$addDateState, action: \.addDateAction)
ifLet
means if addDateState is not nil, MainStore start listening addDateAction.
I created only a state not store, so the store is also required with.
.ifLet(\.$addDateState, action: \.addDateAction) {
AddDateStore()
}
.presented
Now can we receive sub actions like this?
case .addDateAction(.saveButtonTapped):
return .none
Unfortunately there is one more thing. To access sub actions in case, we need through .presented
.
case .addDateAction(.saveButtonTapped):
state.addDateState = nil
return .none
case .addDateAction(.presented(.cancelButtonTapped)):
state.addDateState = nil
return .none
Even though I implemented all sub actions, there is a error.
I resolved one more case without the sub action detail.
case .addDateAction:
return .none
Scope
Now let’s see scope
again.
Scope can export only one sub store with ifLet
.
.sheet(item: $store.scope(state: \.addDateState,
action: \.addDateAction)) { addDateStore in
AddDateScreen(store: addDateStore)
}
This means the sheet
is listening sub store created by the specified child state and action.
Bindable
Despite of these a lot of task, I encountered the error.
This was cause by mistyping of @Binadble
. What is Bindable?
Is it different to Binding
? To bind it’s properties, Bindable
is required. Read more here.
Actually I misread Binding
. So I replaced @Binding
s with @Bindable
.
struct AddDateScreen: View {
@Bindable var store: StoreOf<AddDateStore>
...
}
struct ContentView: View {
@Bindable var store: StoreOf<MainStore>
...
}
Bindable doesn’t need to use .constant
.
#Preview {
AddDateScreen(store: Store(initialState: AddDateStore.State(), reducer: {
AddDateStore()
}))
}
Unfortunately this is not working
init(store: StoreOf<MainStore> = Store(initialState: MainStore.State()) {
MainStore()
}) {
_store = store
}
Use this approach.
_store = .init(store) // .init(wrappedValue: store)
Dependency
We can see new screen with Action and close it. But to append new date into model container. I had to access it from Store
.
Should I add ModelContainer
variable into Store
? or pass the container instance into Action?
Dependency
There are official ways to inject dependencies.
It seems like to fetch common global dependencies. How can we take custom dependencies?
Lets dive into Dependency
. This attribute takes KeyPath
specifying DependencyValues
as Key
type.
So I created extension property for DependencyValues
. What is DependencyValues
? I couldn’t find about it on online documentation. But I found on local document.
To declare custom DependencyValue, we should create also custom DependencyKey
.
Following above guide, I created Dependency for ModelContainer like this.
private enum DatabaseDependencyKey: DependencyKey {
static var liveValue: ModelContext? = nil
}
extension DependencyValues {
var database: ModelContext? {
get {
self[DatabaseDependencyKey.self]
}
set {
self[DatabaseDependencyKey.self] = newValue
}
}
}
I could fetch ModelContext
from Dependencies like @Environment.
Initially Default DependencyValues are provided with liveValue
. How to inject dependency into specific Store?
I thought this is possible, but it is not working.
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
...
init(store: StoreOf<MainStore>? = nil) {
_guard let store else {
_store = .init(Store(initialState: MainStore.State()) {
MainStore()
} withDependencies: { dependencies in
dependencies.database = modelContext
})
return
}
_store = .init(store)
}
}
I couldn’t access modelContext
from the constructor. I had to move store creation to App source.
ContentView(store: Store(initialState: MainStore.State()) {
MainStore()
} withDependencies: { dependencies in
dependencies.database = sharedModelContainer
})
This code emitted an error, because sharedModelContainer
is ModelContainer.
However I could extract ModelContext
from ModelContainer
.
withDependencies: { dependencies in
dependencies.database = sharedModelContainer.mainContext
})
Sendable
All errors are resolve. However there is a warning.
This is because ModelContext is not Sendable
. Read more about Sendable if you need.
So I replaced ModelContext
with ModelContainer
.
DependencyValues
private enum DatabaseDependencyKey: DependencyKey {
static var liveValue: ModelContainer? = nil
}
extension DependencyValues {
var database: ModelContainer? {
get {
self[DatabaseDependencyKey.self]
}
set {
self[DatabaseDependencyKey.self] = newValue
}
}
}
...
App
struct ...App: App {
var body: some Scene {
WindowGroup {
ContentView(store: Store(initialState: MainStore.State()) {
MainStore()
} withDependencies: { dependencies in
dependencies.database = sharedModelContainer //.modelContext
})
}
.modelContainer(sharedModelContainer)
}
}
MainStore
MainContext is Main Actor.
Therefore I moved it into .run to execute asynchronously.
return .run { @MainActor send in
database?.mainContext.insert(Item(timestamp: newDate))
send(.finishAddingDate)
}
However .run
is not in main thread.
So I marked the .run
block as MainActor.
When I made the block as MainActor, it is not async any longer.
I removed await
keyword and appended finishAddingDate
action to close the sheet.
case .addDateAction(.presented(.saveButtonTapped)):
...
return .run { @MainActor send in
database?.mainContext.insert(Item(timestamp: newDate))
send(.finishAddingDate)
}
case .finishAddingDate:
state.addDateState = nil
return .none
No more warnings!
These approaches also can resolve the warning, but it seems not good.
I’m newbie. So this ways are probably incorrect. Please tell me better solutions.
Delegate
Our main parent store can process child’s action, but accessing states from parent to child is best way?
To solve this problem, I want to send selectedDate
to parent through Action
directly like this.
case .saveButtonTapped:
return .send(.delegate( .saveDate(savedDate: state.selectedDate)))
To call action like above, we need to define nested action. This is like ViewControllers have delegate
property.
enum Action {
...
case delegate(DelegateAction)
enum DelegateAction: Equatable {
case cancel
case saveDate(savedDate: Date)
}
}
Empty delegate
case is also required to solve compile error.
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
...
case .delegate:
return .none
}
}
}
Next is to receive delegate
from main store instead of pressed actions. no more state.addDateState?.selectedDate
. Don’t forget previous defined cases.
case .addDateAction(.presented(.delegate(.saveDate(let selectedDate)))):
return .run { @MainActor send in
database?.mainContext.insert(Item(timestamp: selectedDate))
send(.finishAddingDate)
}
Using delegate
child can know when parent’s task is completed. Of course to wait parent’s task we need .run
block.
case .saveButtonTapped:
return .run { send in
await send(.delegate( .saveDate(savedDate: state.selectedDate)))
}
But this approach occur error.
To solve this error, I defined constant outside of the block.
let selectedDate = state.selectedDate
return .run { send in
await send(.delegate( .saveDate(savedDate: selectedDate)))
}
Official tutorial suggest this way. I don’t know what is better.
Now we can know when the saving is completed. However can we close sheet from child not parent?
To solve the issue, TCA provides dismiss
dependency. I extract dismiss
method from dependencies.
and called it after invoking delegate
.
return .run { send in
await send(.delegate( .saveDate(savedDate: selectedDate)))
await self.dismiss()
}
dismiss
will clear nested child store. So the sheet will be dismissed.
MainStore
’s finishAddingDate
is useless now. I removed it.
Lastly I appened ProgressView
with isSaving
State.
If you found any help in this post, please give it a round of applause 👏. Explore more iOS-related content in my other posts.
For additional insights and updates, check out my LinkedIn profile. Thank you for your support!
Troubleshootings
Reduce Switch must be exhaustive
case .{SubAction Name}:
return .none
Cannot assign value of type ‘ModelContainer’ to type ‘ModelContext?’
sharedModelContainer.mainContext
Conformance of ‘ModelContext’ to ‘Sendable’ is unavailable: contexts cannot be shared across concurrency contexts
Use ModelContainer
instead ModelContext
.
Main actor-isolated property ‘mainContext’ can not be referred from a non-isolated context.
Attach @MainActor
.
.run { @MainActor send in
Mutable capture of ‘inout’ parameter ‘state’ is not allowed in concurrently-executing code
Use constants defined outside of the block.
let selectedDate = state.selectedDate
//or
.run { [selectedDate = state.selectedDate] send in
References
https://www.hackingwithswift.com/quick-start/swiftdata/how-to-create-a-background-context