Interactive SwiftUI View Using TCA

mein
5 min readDec 9, 2022

--

This demo shows, in TCA, how the state and action are chained together and how they flow between single view, multiple views and collective views.

The end of this demo is to provide draggable points for the Bezier Curve.

This is part of the whole project, SwiftUI Animated Bezier Curve Using TCA.

I am using swift-composable-architecture 0.47.2

Single View

The very simple single view is a draggable circle. There is only one action (movePoint) corresponds to single state variable (pt).

  • Reducer
import SwiftUI
import ComposableArchitecture

struct TCAPointViewReducer: ReducerProtocol {
struct State: Equatable{
var pt : CGPoint
}
enum Action : Equatable{
case movePoint(CGPoint)
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action{
case .movePoint(let pt):
state.pt = pt
return .none
}
}
}
  • View
struct TCAPointView: View {
let store: StoreOf<TCAPointViewReducer>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
Circle()
.fill(.yellow)
.frame(width: 50,height: 50 )
.position(viewStore.state.pt)
.gesture(DragGesture().onChanged({gesture in
viewStore.send(.movePoint(gesture.location))
}))
}
}
}

Multiple Views

Suppose we add another view which could use sliders to change the x and y coordination. We only need to share the store between views. The reducer remain unchanged.

  • View 1
    The same as in single view.
  • View 2
struct TCASliderView: View {
let store: StoreOf<TCAPointViewReducer>
var body: some View {
GeometryReader { proxy in
WithViewStore(self.store, observe: { $0 }) { viewStore in
VStack{
Slider( value: viewStore.binding(get: \.pt.x,
send: {TCAPointViewReducer.Action.movePoint(CGPoint(x: $0, y: viewStore.pt.y))}),
in: 0...proxy.size.width,
label: {Text("label")},
minimumValueLabel: {Text("x: \(Int(viewStore.pt.x))")} ,
maximumValueLabel: {Text("\(Int(proxy.size.width))")}
).padding(10)
Slider( value: viewStore.binding(get: \.pt.y,
send: {TCAPointViewReducer.Action.movePoint(CGPoint(x: viewStore.pt.x, y: $0))}),
in: 0...proxy.size.height,
label: {Text("label")},
minimumValueLabel: {Text("y: \(Int(viewStore.pt.y))")} ,
maximumValueLabel: {Text("\(Int(proxy.size.height))")}
).padding(10)
}
}

}
}
}
  • Share the store between View1 and View2
struct MyPreviews: PreviewProvider {
static var previews: some View {
let pt = CGPoint(x: 0, y: 0)
let store = Store(initialState:TCAPointViewReducer.State(pt: pt),
reducer: TCAPointViewReducer() )
ZStack{
TCAPointView(store: store)
TCASliderView(store: store)
}
}
}

Collective views

For example, views in a list collection.

In TCA, we need to use its own collection type, IdentifiedArray.

Since TCA is composable and testable, we could start from building the reducers.

  • Child Reducer

Since the child reducer is for the IdentifiedArray elements, its state needs to be Identifiable.

struct ChildReducer: ReducerProtocol {
struct State: Equatable, Identifiable{
var id : UUID
var pt : CGPoint
}
enum Action : Equatable{
case movePoint(CGPoint)
}
func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action{
case .movePoint(let pt):
state.pt = pt
return .none
}
}
}
  • Parent Reducer

The simplest parent reducer is doing nothing, but only to join the child reducer to the parent reducer.

Make sure you understand the simplest code, so that you will not get lost when the code becomes more complex.

struct ParentReducer: ReducerProtocol {
struct State: Equatable{
var children : IdentifiedArray<ChildReducer.State.ID, ChildReducer.State>
}
enum Action : Equatable{
case joinReducerAction(ChildReducer.State.ID, ChildReducer.Action)
}
var body: some ReducerProtocol<State, Action> {
Reduce{state, action in
return .none
}
.forEach(\.children, action: /Action.joinReducerAction) {
ChildReducer()
}
}
}
  • Unit Test
@MainActor
class BasicTests: XCTestCase {
func test() async {
let uuid = UUID()
let child = ChildReducer.State(id: uuid, pt: CGPoint(x: 0, y: 0))
let store = TestStore(initialState: ParentReducer.State(children: [child]), reducer: ParentReducer())
await store.send(.joinReducerAction(child.id, .movePoint(CGPoint(x: 100, y: 100)))){
$0.children[id: uuid]!.pt = CGPoint(x: 100, y: 100)
}
}
}
  • Child View

It is the same as in the single view. Just some naming changes.

struct ChildView: View {
let store: StoreOf<ChildReducer>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
Circle()
.fill(.yellow)
.frame(width: 50,height: 50 )
.position(viewStore.state.pt)
.gesture(DragGesture().onChanged({gesture in
viewStore.send(.movePoint(gesture.location))
}))
}
}
}
  • Parent View

First we draw each child point and then we draw a Bezier Curve accordingly. The Bezier Curve is not animated yet, but it is already interactive. As you drag each point, the shape of the curve will change.

Notice how the parent store is propagated to child store. There is some TCA syntax you need to be familiar with.

struct ParentView: View{
let store: StoreOf<ParentReducer>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
ZStack{
ForEachStore(store.scope(state: \.children, action: ParentReducer.Action.joinReducerAction)){childStore in
ChildView(store: childStore)
}
BezierShapeView(ptArray: viewStore.children.map({$0.pt}))
}
}
}
}

struct BezierShapeView: View {
let ptArray : [CGPoint]
var body: some View {
Path { path in
path.move(to: ptArray[0])
path.addCurve(to: ptArray[3],
control1: ptArray[1],
control2: ptArray[2])
}
.stroke(Color.blue)
}
}
  • Preview
struct ParentView_Previews: PreviewProvider {
static var previews: some View {
ParentView(store: Store(initialState: .init(children: IdentifiedArray(uniqueElements: [ChildReducer.State(id: UUID(), pt: CGPoint(x: 30, y: 60)),
ChildReducer.State(id: UUID(), pt: CGPoint(x: 90, y: 250)),
ChildReducer.State(id: UUID(), pt: CGPoint(x: 250, y: 250)),
ChildReducer.State(id: UUID(), pt: CGPoint(x: 300, y: 150))])),
reducer: ParentReducer()))
}
}

Conclusion

Interactive SwiftUI views are a good entry point to learn TCA framework because action and state are forming a close feedback loop.

And by realizing how astonishing animation effect could be achieved by straightforward TCA code, it is very entertaining to learn in this way, too.

All the code in this article could also run in Swift Playgrounds. Though the final project is not complete compatible with Swift Playgrounds, I will try to find out how far I could go with Swift Playgrounds as the codebase grows more and more complex.

--

--

mein
mein

Written by mein

Let the code flow as the mind flows.

Responses (1)