EP 97 · Adaptive State Management · Apr 6, 2020 ·Members

Video #97: Adaptive State Management: The Point

smart_display

Loading stream…

Video #97: Adaptive State Management: The Point

Episode: Video #97 Date: Apr 6, 2020 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep97-adaptive-state-management-the-point

Episode thumbnail

Description

We’ve now shown that the Composable Architecture is also quite adaptive, allowing us to transform state and actions into very domain specific situations. Let’s exercise those muscles by creating a macOS app from our iOS app and demonstrating that it’s the perfect tool for creating cross-platform applications.

Video

Cloudflare Stream video ID: ed1a1c64b204ec91a8b112c716eb1f20 Local file: video_97_adaptive-state-management-the-point.mp4 *(download with --video 97)*

Transcript

0:05

All of this together means our views are even more logicless, more direct, and easier to understand. But on Point-Free we like to always ask the question “what’s the point?” so that we can try to convince you that we are getting real benefits from these things and not just describing something that looks cool on the surface, but really isn’t that useful in practice.

0:26

And although it can be argued that are views are a bit simpler, it did come at a cost. We had to introduce an all new state struct and action enum for the view, we have to hold onto a whole new object in our views, and we have to provide transformations to construct the view stores. That seems like a lot of extra work to do for each of our views.

0:47

So, what’s the point? Is it worth doing this?

0:50

We of course think it is! To begin with, if your view is simple or if you don’t think there are any performance concerns in your app or for a particular screen, you can derive your view store by just hitting .view on your store and you’re done. No extra ceremony necessary!

1:17

However, once your application grows in size and use cases, you may find that the view store abstraction is exactly what you need to wrangle in complexity and unlock new capabilities. And to prove this, we are going to implement the feature that all of our PrimeTime users out there have been begging us for: a Mac app 😂.

1:37

OK, so there’s maybe not a huge demand out there for a Mac version of our wonderful little counting and prime calculator app, but it will help us demonstrate a key strength in using the view store. We can generalize our business logic so that it isn’t necessarily concerned with platform features, and then use view stores that adapt that generic functionality into a specific platform, like iOS, watchOS, macOS, tvOS or all 4.

2:05

And to demonstrate this we are going to build a Mac version of our application that has some subtle differences from the iOS version that we have been building so far. In particular, right now when we ask if the current count is prime we show a modal, but on Mac we will show it as a popover because popovers on macOS are super lightweight and unfortunately are not supported on iPhones. We will also remove the double tap gesture for asking for the “nth prime”, as that type of gesture isn’t super common on Mac. Cross-platform SwiftUI views

2:54

Let’s see what happens if we just try to naively adapt some of our views to be Mac friendly.

3:00

First it must be said that a bit of work needs to be done to get an Xcode project into shape where it can build frameworks for iOS and macOS at the same time. In particular, we have these xcconfig files in our project, and they’ve been added to all of the frameworks in this project.

3:33

Once that is done we can try building the Counter module for Mac, but we will immediately get an error: .navigationBarTitle("Counter demo") Value of type ‘some View’ has no member ‘navigationBarTitle’

3:41

Turns out that macOS applications have no concept of “navigation bar titles” because that’s not what the UI looks like for Mac apps. Only iOS, watchOS and tvOS applications are capable of using this view modifier. So we’ll need to special case this somehow for macOS. One way we could do this is extract out a little helper property for views that bakes in that special logic: extension View { var counterNavBarTitle: some View { #if os(macOS) return self #else return self.navigationBarTitle("Counter demo") #endif } }

5:11

And then in the view we can just do: .counterNavBarTitle

5:22

And it will just do nothing if we are on the macOS platform.

5:28

But that is only the tip of the iceberg. There are tons of APIs that only sense for a single platform or a subset of the platforms. For example, action sheets are not on macOS, so if you plan on using those APIs in a cross-platform app you will need to do more special casing. There are also some APIs that are on iOS and macOS, but not on watchOS or tvOS, like RotationGesture and MagnificationGesture .

6:00

But beyond that little problem, we also have this bit of code that shows a modal whenever you tap on the “is this prime” button: .sheet( item: .constant(self.viewStore.value.isPrimeModal), onDismiss: { self.viewStore.send(.primeModalDismissed) } ) { isPrimeDetail in IsPrimeModalView( store: self.store.scope( value: { (isPrimeDetail.count, $0.favoritePrimes) }, action: { .primeModal($0) } ) ) }

6:22

For the macOS version of this app we’d actually like to use a popover instead of a modal. We want to do this before popovers are a super lightweight way to show a little bit of extra UI related to some other piece of UI, and popovers don’t even work on iPhones, only on iPads.

6:45

The API for popovers is very similar to modals, but we still need to do extra special casing logic in order to show either a popover or modal depending on the platform. We could maybe try to do that special casing in a view extension helper, something like: extension View { var primeDetail: some View { #if os(iOS) // modal #elseif os(macOS) // popover #endif } } Dedicated platform SwiftUI views

7:10

And all of these little discrepancies are really going to add up. In a few months of working on this feature we may have dozens of little tiny tweaks in this view that depend on whether we are running the iOS app or the Mac app.

7:30

Fortunately SwiftUI has a really nice story for the problem we are seeing here. SwiftUI takes the stance that the construction of the UI should perhaps be the simplest part of your application. After all, it’s just a simple function that maps the state of the view to a hierarchy of SwiftUI types, consisting of stacks, buttons, text views, lists and more. And because the construction of UI can be made so simple, it is perhaps ok to duplicate the view code for each platform you want to support when their UI significantly deviates.

8:08

And we completely agree with this position that SwiftUI has taken. Often times the conventions and design of a Mac view is different enough from an iOS view that it might be better to just create the view from scratch for each platform rather than abstract away their commonalities and unify their differences.

8:40

So that is exactly what we are going to do. We are going to extract out our CounterView into a new file, called CounterView_iOS.swift , and we are going to wrap its definition in: #if os(iOS) import ComposableArchitecture import PrimeAlert import PrimeModal import SwiftUI … #endif

9:45

And then we are going to make a copy of that file, call it CounterView_macOS.swift , and change the condition to: #if os(macOS) … #endif

10:20

Even better than having this little #if conditionals all over the place would be to create all new frameworks that hold just the iOS or macOS specific stuff. Then the Counter module can be concerned only with the agnostic parts of your business logic, and nothing having to do with platform-specific implementations. But we will leave that as an exercise for the viewer.

10:48

Now that we have a view that is specifically made for macOS, we can start making changes to it to make it compile and be more Mac-friendly. To start, let’s get rid of the navigation bar title: // .navigationBarTitle("Counter demo")

10:59

Next we want to address the issue of the modal sheet, which we would like to render as a popover on macOS. We just need to switch from the sheet API to the popover API and construct a binding from our store. .popover( isPresented: Binding( get: { self.viewStore.value.isPrimeModalShown }, set: { _ in self.viewStore.send(.primeModalDismissed) } ) ) { IsPrimeModalView( store: self.store.scope( value: { ($0.count, $0.favoritePrimes) }, action: { .primeModal($0) } ) ) }

12:09

But the naming of our state and actions isn’t ideal. Months from now when we come across this code we may be confused at the fact that we are showing a popover even though the state is clearly telling us it is meant for a modal.

12:52

We can do better. We will make our core business logic more agnostic to the UI, and allow each of our views to transform that agnostic state into something that makes sense for them.

13:07

So rather than name the state specifically after the type of UI that will be shown, let’s name it after the fact that when you ask if the current count is prime we show some kind of detail screen, whether it be a modal, a popover, an alert or what have you. We will call it isPrimeDetailShown : public typealias CounterState = ( … isPrimeDetailShown: Bool )

13:45

And instead of calling the action isPrimeModalDismissed , we will make it more agnostic by saying isPrimeDetailDismissed : public enum CounterAction: Equatable { … case primeDetailDismissed }

13:54

Which means in the reducer we now need to work with that new state and action: case .isPrimeButtonTapped: state.isPrimeDetailShown = true return [] case .primeDetailDismissed: state.isPrimeDetailShown = false return []

14:15

And then we need to update the CounterFeatureState , which is the struct that holds all of the data for both the counter UI and the prime modal: public struct CounterFeatureState: Equatable { … public var isPrimeDetailShown: Bool public init( … isPrimeDetailShown: Bool = false ) { … self.isPrimeDetailShown = isPrimeDetailShown } var counter: CounterState { get { ( self.alertNthPrime, self.count, self.isNthPrimeRequestInFlight, self.isPrimeDetailShown ) } set { ( self.alertNthPrime, self.count, self.isNthPrimeRequestInFlight, self.isPrimeDetailShown ) = newValue } } … }

15:02

That’s all we need to update in this file, So let’s then hop over to CounterView_macOS.swift and get our Mac app building. Its local domain is not right because it has mention of the modal, and we want to use popovers for the macOS app, so let’s rename accordingly: struct State: Equatable { … let isPrimePopoverShown: Bool } enum Action { … case primePopoverDismissed }

15:40

Also remember that we already swapped out the modal for a popover, and that code looked like this: .popover( isPresented: Binding( get: { self.viewStore.value.isPrimePopoverShown }, set: { _ in self.viewStore.send(.primePopoverDismissed) } ) ) { IsPrimeModalView( store: self.store.scope( value: { ($0.count, $0.favoritePrimes) }, action: { .primeModal($0) } ) ) }

16:09

And then we need to update the transformations that get our state and actions into shape for the view store: extension CounterView.State { init(counterFeatureState state: CounterFeatureState) { … self.isPrimePopoverShown = state.isPrimeDetailShown } } extension CounterFeatureAction { init( counterViewAction action: CounterView.Action ) -> CounterFeatureAction { switch action { … case .primePopoverDismissed: return .counter(.primeDetailDismissed) … } } }

17:05

Everything’s building now, but there’s still a bit of domain-specific naming we could content with. For one we can see that the IsPrimeModalView name is probably a little misleading. It should maybe also be generalized to IsPrimeDetailView , but also perhaps its functionality will need to be split into a macOS and iOS version someday.

17:51

There’s also some logic that we want to eliminate from the Mac app. We don’t want to support that double-tap gesture, so we can comment it out. // .frame( // minWidth: 0, // maxWidth: .infinity, // minHeight: 0, // maxHeight: .infinity // ) // .background(Color.white) // .onTapGesture(count: 2) { // self.viewStore.send(.doubleTap) // }

18:45

Which means we can also get rid of the case doubleTap in the action enum: enum Action { … // case doubleTap } … extension CounterFeatureAction { init(counterViewAction action: CounterView.Action) -> CounterFeatureAction { switch action { … // case .doubleTap: // return .counter(.requestNthPrime) } } }

18:59

While things are now building for Mac, we’ve broken our iOS implementation in changing the domain. Let’s hop over to the CounterView_iOS.swift file where we need to make two small changes where we construct our view store state and action: extension CounterView.State { init(counterFeatureState state: CounterFeatureState) { … self.isPrimeModalShown = state.isPrimeDetailShown } } extension CounterFeatureAction { init( counterViewAction action: CounterView.Action ) -> CounterFeatureAction { switch action { … case .primeModalDismissed: return .counter(.primeDetailDismissed) } } } And this is pretty cool. Here we are given the agnostic value of isPrimeDetailShown and we get to map it into a more domain-specific value of isPrimeModalShown since that is what the iOS UI cares about. And then we are given the more domain-specific value of primeModalDismissed and get to map it to the agnostic value of .counter(.primeDetailDismissed) , which is what the reducer powering the business logic understands.

19:32

And just like that we have a framework that is compiling for both iOS and macOS that contains the core business logic that is shared between both platforms, as well as views that have been properly adapted for each platform. Each platform view works with state and actions that make sense for its platform, and doesn’t have any agnostic domain that needs to be interpreted, or extra domain that is not applicable to the view. Cross-platform playgrounds

20:10

But how can we test our new Mac view? Well, we could try to get a full PrimeTime Mac application going, but that will take a bit of work to get up and running.

20:29

Well, the work we did to modularize our app, which was ~25 episodes and 6 months ago, still continues to pay dividends for us today. That work has allowed us to repeatedly experiment with our application and build small parts in isolation without worrying about getting the full application compiling. It has empowered to us try out bold, new refactors without getting bogged down in a mountain of work, and we are again getting the benefits. We are able to just get this one single screen compiling for macOS so that we can give it a spin without needing to worry about the whole application.

21:01

So, let’s do it!

21:04

We can start by hopping over to our counter playground, which is no longer building because we’ve changed the domain names. We need to update isNthPrimeButtonDisabled state to isNthPrimeRequestInFlight . isNthPrimeRequestInFlight: false

21:35

That gets the playground compiling again for iOS, but if we change our playground settings to run on the Mac, it’s still not quite in building order. Use of unresolved identifier ‘UIHostingController’

22:15

This is because we’re relying on UIHostingController , which is specific to the iOS platforms. On macOS there is a similar NSHostingController , but rather than having to switch back and forth between them, it’d be nice if this playground built on both platforms with the same code. Luckily, there is a setLiveView helper method on the playground page that directly takes a SwiftUI view. PlaygroundPage.current.setLiveView( CounterView( … ) )

22:42

And just like the playground is running, but it looks a little strange.

22:53

This is happening because in the iOS app we overrode the font size to be quite big, but it seems that macOS buttons don’t play nicely with that. For macOS maybe we shouldn’t mess with the font size, and just use what is native. To do that we can just remove our font modifier: // .font(.title)

23:23

And now if we build and run our playground we see something more reasonable.

23:33

It’s nice that we can do customizations like that to our Mac app without needing to do a bunch of if/else special casing logic.

23:49

And this Mac view totally works too. We can count up and down, we can ask if a number is a prime, which brings up a popover that allows us to save and remove the prime from our list of favorites. Notice that we are also getting state persistence just like with our iOS app. If we go to another prime and ask if its prime we will see it has not be saved to our favorite, but if we go back to our first prime and ask, we will see it remembered that it is already in our favorites.

24:06

Further, we can even ask for the “nth prime”, and we will see an alert.

24:17

Right now this is executing our mock effect, which just immediately returns a value, but we can also put in the live environment that calls out to the Wolfram Alpha API: environment: Counter.nthPrime

24:33

And incredibly we are now executing the real “nth prime” side effect, which makes a network request to the Wolfram Alpha API, and then it feeds its result back into the store which triggers this alert to show. Conclusion

25:37

This is why we think it’s ok to embrace a little bit of duplication for our views in support of sharing the business logic. We are getting to share the true brains of our feature between two views tailored to two different platforms. The fact that the effects that powered the iOS application are seamlessly working for the Mac app is kind of amazing. And remember that we have very good test coverage on these effects, so we can rest assured that things will work as we expect when run on both iOS and macOS.

26:10

And this is the point of all the work we have been doing for the adaptive state management series of episodes. We have unlocked the ability to make our core business logic agnostic to the use cases in the UI, which is where all the really tough work happens for our application, while still allowing ourselves to adapt that agnosticism into something domain-specific for our views.

26:43

We are getting multiple layers of benefits all at once. We are on the one hand simplifying by generalizing how the business logic is handled, while on the other handle simplifying by specializing how we construct our view from application state.

27:06

And that finishes this series of episodes for adaptive state management. We are in the final stretches of getting the Composable Architecture into a production-ready state. We just have a few more things to cover, but we’ll leave that for next time. Downloads Sample code 0097-adaptive-state-management-pt4 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .