Presenting a modal(sheet) with TCA in SwiftUI

Lee young-jun
10 min readMar 29, 2024

--

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?>

itemA 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 @Bindings 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/whats-the-difference-between-bindable-and-binding

https://www.hackingwithswift.com/quick-start/swiftdata/how-to-create-a-background-context

--

--

Responses (1)