Donny Wals

Building Custom Property Wrappers for SwiftUI (And How to Test Them)

Property WrappersSwiftUIArchitecture
Transcript

0:00:33.6 Donny Wals: Good afternoon everybody. Do we still have some energy for a couple of more talks? Yes. Good. Alright, so my name is Donny. I don't have to talk too much about myself, I guess. I write stuff, I teach people stuff like Greg mentioned, we did a workshop for AirBnb in the summer, not on any of these two topics, but if you're interested in learning Combine or Core Data, you can either Google the names of my books or just scan the QR codes or whatever, and you'll find them and you can learn more about that. But today I'm here for a completely different talk. It's about building custom SwiftUI property wrappers. And the reason I'm talking about this is because I've done a lot of SwiftUI work in the past, year or so, and I've learned a lot about with SwiftUI and I've learned a lot about how it works and how we can make apps that... Or write code that feels like it belongs in SwiftUI, so in this talk what you'll learn. First and foremost, we're gonna talk about what property wrappers are and how they work, I'm sure that most of us will have used property wrapper in one way or the other, but do we actually know what they are and do we know how we use them? Well, you know how to use them, but how they actually work on the inside and how they take whatever they wrap and manipulate that potentially.

0:01:50.6 DW: And of course, with SwiftUI is special, as we all know, so a regular property wrapper obviously will not be good enough for SwiftUI and we're going to talk about why that is and how we can make a good one. And here's the really the interesting part, because we're going to build a property wrapper that taps into SwiftUI's view environment and life cycle, which means that we can make our property wrapper really part of what it SwiftUI can do, which is amazing. And of course, here's the most important part that everybody always asked about, how do we test this thing in SwiftUI because SwiftUI views are (obviously... Or maybe not, obviously, if you've not tried it) notoriously hard to test; and this property wrapper will have similar issues and we'll figure out how we can test it anyway.

0:02:34.6 DW: So if you've done SwiftUI I'm sure code like this looks pretty familiar. It's super basic. And the interesting part here is this, the @State property wrapper. So when we use this, what does it actually do? Well, first and foremost, it's a container that holds some mutable value, it allows us to mutate values or data that's held in a struct, otherwise we would not be able to do this, but with state we can...

0:03:00.6 DW: And what's interesting is that whenever we mutate that value the view will update it, so SwiftUI will redraw our view whenever we change our state. And what's also interesting is that whenever our views initializer is called, that state does not reset, and that might make a lot of sense to you initially, but if you try and remember the code, you just saw, we said var isActive = false, which means that whenever we initialize that view, that's gonna be the default value, however, if we change it and we use state somehow that new value is retained whenever that view gets re-initialized, which is pretty cool. And also, you can obtain a binding two state property, which means that we can give it to another view and allow the other view to change the state that is owned by the source view. All pretty cool stuff.

0:03:47.6 DW: Another example of what you may have seen before is this code, it uses the @FetchRequest property wrapper. If you're not familiar with this one, it allows us to fetch data based on a Core Data entity or fetch request, so we can say, "Hey, SwiftUI go to Core Data and get me a list of posts or give me a list of users or whatever we have stored". What's also really cool is that this fetch request property wrapper will observe Core Data and tell SwiftUI whenever our fetch data has changed, which means that our view now updates whenever our data updates, which is fantastic. And here is a really interesting thing: it leverages the SwiftUI environment for its dependencies, because if you're familiar with Core Data, you'll know that we can only fetch data from a managed object context and the @FetchRequest property wrapper will take a managed object context from the SwiftUI view environment.

0:04:45.6 DW: And SwiftUI comes with many, many, many property wrappers, if you're working with SwiftUI, you'll be using them all over the place, not thinking about it too much, but if you do start thinking about it, and if you start thinking about what appears to be Apple's vision with with SwiftUI, we can come to a few conclusions:

  • one is that Swift live views are apparently completely okay to own or receive state directly, and this is something we're not used to in UIKit. In UIKit you want to have your state or your logic or your business logic or data sources all in view models for other objects. Whereas in SwiftUI apparently, given that we have a state property wrapper and a fetch request property wrapper and things like app storage, scene storage and whatnot, it's okay for views to own this state; we can also conclude that apparently we leverage property wrappers to obtain and hold data.
  • And here's a very important one, we technically don't need extra layers to fetch data, right? If we have our Core Data store, we can use a @FetchRequest property wrapper to take stuff out of it, and apparently, according to Apple, that's completely okay. (Whether or not you agree - let's set that aside, 'cause we're thinking about what Apple apparently is thinking).

0:06:01.6 DW: So given all of this, wouldn't it be cool if we could write code like this, right? And if it doesn't stand out what I'm talking about here, look at this: @RemoteData(endpoint: .feed), that gets me a feed of posts. This is really cool and it feels right at home, in SwiftUI, it looks exactly like the @FetchRequest or @State property wrappers.

0:06:20.4 DW: So let's go and build this, let's see if we can do that. Before we do, let's quickly talk about property wrappers for just a minute, we can define property wrappers by writing the @propertyWrapper annotation on top of a class or a struct. There's one requirement for a property wrapper, and that is that it has a wrappedValue, so that means that this property wrapper @StringSample is going to wrap some value that is a string, it can't wrap anything else. A lot of property wrappers will use generics, but ours doesn't in this case; and we have a projected value: this is a value that we can provide whenever somebody accesses our property wrapper using a $ prefix, which is exactly how binding works - a binding is a projected value for state in SwiftUI.

0:07:06.4 DW: So if you look at how this would be used, it could look a little bit like this: we have our @StringSample property wrapper that we apply to the string "Hello, world", so that becomes the wrap value and we have three ways to access this property wrapper:

  1. We can access example with no prefix that will get us the wrapped value of the property wrapper
  2. We can use the _ prefix that will get us an instance of the property wrapper itself; in this case, "StringSample"
  3. And lastly, we can use the $ prefix to get the projected value; in this case, that would be another string, "Projected: Hello, world", but it could also be an integer that represents the count of my string or anything else.

0:07:45.4 DW: Before we continue, just a quick reminder, this is our goal: a property wrapper that we give an endpoint to get a few of those. So let's start with the basics of what we've just seen, we just define our property wrapper class RemoteData, it has an endpoint that we're gonna fetch data from. Under wrappedValue here, I've hard coded that to be posts; of course, in a real property wrapper this would be generic, but to keep things a little bit simpler because we all still have not digested Antoine's talk, we're gonna hard code this. Also, it's like almost 6:00 PM. So we're not gonna do generics!

0:08:21.1 DW: Okay, so this is the basics, but obviously this doesn't fetch data yet, so we're gonna add a simple data fetch function, and if everybody's paying attention, can we all read this? You're doubting me right now, can we do that? Thank you. Yes, because I told you these wanna be testable, and if we're using this, obviously this cannot be tested: this is very, very bad. And we'll get to that, right? But first, I want to talk about that function and then we're going to improve this as we go, so we have the fetchData() and we update the wrapped value with data that we fetched, so our wrapped value for the property wrapper, initially it will be an empty array that's gonna hold posts, we fetch data, and then we decode our fetched data into posts and we make that the wrapped value, so now we have a list of posts. In this code snippet, I have a function called URL.for(), and I showed you that Endpoint object, and I just quickly wanna show you this because they're really not interesting at all (but this is literally how I coded them in the sample, so if you were worrying about What does those things do? Stop, worrying now. They're not interesting at all.)

0:09:26.1 DW: Okay, about this ('cause this is really what the whole talk is about) where do we grab our networking layer from? 'Cause if you remember what we were trying to build, we have this @RemoteData, give it an endpoint, and we magically get a list of posts, so we're not doing dependency injection there, we're not going to be able to say, "Oh, this is the networking layer to use". So we need to get it somehow, and if we think back to what @FetchRequest does, it takes the managed object context from the SwiftUI view environment, so how about we grab our networking layer from the SwiftUI environment. And if you've ever tried to do something with SwiftUI, you'll know that, well, the environment is only available to SwiftUI views, we can't just randomly grab the environment: we have to be part of the hierarchy of SwiftUI to be able to do that. We have a tool for that, DynamicProperty. DynamicProperty is a protocol with only one requirement, that is that we implement an .update() function, and SwiftUI will call that function whenever we need to update our fetched data or whenever we need to update our underlying data.

0:10:32.4 DW: What's really cool is that we have something that conforms the DynamicProperty that exists within a SwiftUI view, it makes EnvironmentValues available to our property wrapper, which means that with dynamic property, we can now take whatever we want out of the SwiftUI environment. There's one important remark, we should not internally store state, and I'll get to that later, but just planting that seed in your head right now. What's also really cool is that DynamicProperty can hold observable objects and even things like state, to tell us SwiftUI that something changed, it essentially becomes a SwiftUI view, except it's not a view, it's in our case a data fetcher.

0:11:12.4 DW: So let's go ahead and set this whole thing up, we add a new environment key to SwiftUI, so we make a URLSessionKey that has some default value (we can try to do that with fatalError, you could also just use URLSession.shared here of course) and we add a new environment value property to SwiftUI as well, so that we can access this in our views.

0:11:31.2 DW: We also update our property wrapper, so we make remote data now conform to DynamicProperty, and we grab the URLSession from the environment - easy as that, so we could update the fetchData() function as well to use this URLSession, instead of URLSession.shared and we'd be good. We also implement the update() function so that whenever SwiftUI tells us "it's about to evaluate the view body, you can update your data now", we go off to the network, fetch our data, assign it to a wrap value and SwiftUI redraws. Okay so let's use that .environment key. We set up a whole app PostView environment .urlSession as URLSession.shared, because in our app it's fine to use that one. In our test, we would use a different one; and we build a simple view, the code that you've seen before, basically. So that's a lot of talking I just did and a lot of showing you some codes. So Here's what should be happening, study this for a second. While, I drink some water.

0:12:32.1 DW: So we have the PostView. SwiftUI calls the body, @RemoteData has to update, we fetch our data, we update wrappedValue, and we have SwiftUI re-evaluate the body to show our new list. Great, we implemented all the moving parts, we run the app, and sadly, it just doesn't work. When is anything ever easy with SwiftUI? So what could be wrong here? Looking at that code, I know you're all looking for this, I didn't forget to .resume the network call. We know that in update(), fetchData() is called, so that's also not the issue, and we know that wrapped value is updated when we have our data. So what is wrong here? And if you've done work with SwiftUI before, you know that we should somehow tell SwiftUI that new data was fetched and just updating a regular old property is not going to redraw anything. So we need to make our wrapped value state, for example, so that whenever we change this, SwiftUI is like, "Ah, something that is important marked with state changed, so we need to update the view." Okay? So with that in place, we run the app again, and obviously it still doesn't work because Apple has documentation on DynamicProperty, but they hid one very important requirement: I fixed the code here, can anybody see what I changed? Take a second to look at it while I drink more. Wow. Who was that? Good eye. Because yes, that is the one change. I changed my remote data from a class to a struct because apparently DynamicProperty only works if we use a struct. If we make it a class, SwiftUI is like, "Oh, classes, no, no, no. Structs, please!" So one more try and if it doesn't work, I'll walk off stage. Because obviously, it works. So with remote data being a struct, we now can fetch data and we have what we need.

0:14:30.1 DW: So what do we have so far? We have a struct called remote data that conforms to dynamic property, it grabs a URL session from the SwiftUI environment, uses @State on a wrappedValue to tell SwiftUI something changed, and whenever SwiftUI calls update(), we fetch data. So here's what happens. You saw this graph just now, and there is a pretty big issue here because SwiftUI is going to evaluate the body, as it does that, we have our update() function called, we fetch data, we update the state property which will make the body be evaluated, whenever SwiftUI does that, it calls update(). So we keep going in a circle until SwiftUI decides that nothing changed, then it won't redraw. So this happens a couple of times. Because we fetch data on every call to update: we don't wanna do that, so we need to update the property wrapper one more time to make sure that we track whether we are loading data or not and whether we have fetched data before or not.

0:15:24.1 DW: So we could do something like this: add a var isLoading to the property wrapper, set it to check whether we are loading or not; and also I check whether the wrapped value is empty because when it's not empty, I fetched my data, and I'm not interested in doing that again. I set isLoading to true, I set isLoading to false when we're done. It's all great. We try to compile this and Xcode starts yelling at us, because self is immutable. self is a struct, so we can't just go around changing properties on it. However, we know from SwiftUI, that we could use @State here, because state puts our property in a separate storage and we can now change it whenever we want, everything is good, we run the app, it compiles, and we get this warning, "Modifying state during view update, this will cause undefined behavior." Okay, what can we do about this? We can't just make this a non-state property, so we can make this so that it doesn't trigger a view update because then we would be making this a class which was not allowed.

0:16:25.2 DW: So what we should do is we should move the underlying data away from the wrapper. So we should make the whole data loading the responsibility of another object that will track whether it's loading stuff or not. It can be a class, and that the property wrapper simply becomes an interface for that object. Okay, so we start doing that, we make a DataLoader: it's an ObservableObject, which is very important because now we can use that in our property wrapper as an @ObservedObject or a @StateObject, just like we would in a view. We have a @Published property loadedData, so whenever that changes, the SwiftUI view will change. We keep track of whether we're loading or not. We have an optional urlSession and RemoteData.EndPoint, and this is very important, because you might be thinking, "Well, you should pass that to the initializer for the data loader, use it there." But we can't. And I'll show you why in a moment. Then, an fetchDataIfNeeded(), we check whether we have a urlSession endpoint, whether we're loading, whether we have loaded before and we kick off the work just like before; flip isLoading as needed and everything works. And again, because DataLoader is an ObservableObject, it can tell our property wrapper that it changed and that will in turn redraw our view, which is great.

0:17:40.2 DW: So we have the DataLoader and then we update the view. View now has a @StateObject dataLoader and it creates that without passing at the environment end point. We have the wrappedValue, which is now simply a computed property taking whatever the loaded data for our data loader is, and in update function, we check whether we have already assigned a URL session to the data loader and an end point, if not, we do it, and after that, we call fetchDataIfNeeded(). Now, the reason we can't immediately initialize the data loader with our end point and with our urlSession is that the SwiftUI environment is not set up when this property wrapper is initialized - or the environment is set up at the point that SwiftUI evaluates the body, not earlier. So we can't assume that we have access to the urlSession when the property wrapper is initialized, which means that in update(), we can only reliably read from the environment, hence why I do this.

0:18:39.0 DW: Okay, so the property wrapper is now pretty tiny. We moved all the data loading logic into the data loader and here is the final diagram:

  1. We have a PostView.body
  2. Whenever that's evaluated, we update the data loader with fetchDataIfNeeded();
  3. If there was a data loaded...
  4. ...we update the published property, which will make:
  5. the objectWillChange for data loader emit,
  6. which will in turn updates the view.

A lot of things happening. If we didn't load data, nothing happens. That wa s quite the adventure. You can imagine that I'm talking about this in 20 minutes now on stage as if it's all super obvious and clear. This took me like a week to figure out, why does this not work properly? But in the end, I got it to work and I was able to write my code like this, which I really, really liked 'cause it feels like this should be on a WWDC slide basically.

0:19:31.2 DW: All right, so wrapping up DynamicProperty:

  1. They allow us to build property wrappers that drive SwiftUI views. They allow your objects to be almost a part of the SwiftUI view hierarchy, which means that you can offload your logic into a separate object and hide that from the view while still having it directly in the view. And it makes it look very similar to what Apple does with @State and with all their other property wrappers.
  2. This is the best part for me: it can tie into SwiftUI's environment, which means that you get the dependency mechanism that SwiftUI uses right inside of those objects.
  3. They can leverage SwiftUI state related property wrappers because again, they basically become views themselves except they're not actually views.
  4. And apparently they should be structs (I found that out the hard way!)
  5. The update() method has called for every body evaluation, so you want to make sure that you check whether there's actually any work to be done. And I found that typically that means that the property wrapper shouldn't own its state, because it's going to be fetching data from some place, and you might wanna have a class where you can freely mutate things and freely manipulate things as you need.
  6. Bonus. A dynamic property does not have to be a property wrapper. So you could even make your view model if you want to use that as a @StateObject, a dynamic property and it will get access to this SwiftUI view environment. So it is actually quite convenient. Just like the property wrappers syntax, but you don't have to use it.

0:21:00.7 DW: So part two, how do we test this thing? Because we're dealing with the SwiftUI view, we should create a hosting environment in which the view can be set up so that we get access to the SwiftUI environment and all of that stuff. We don't get that if we just make an instance of a struct. We also need a view to test with. 'Cause we need some view to have that property wrapper to see if it actually updates. We'll need to somehow know when the view redraws. We can't just look at the body property and have to test it while we hope that it maybe changes. And ideally, we actually also would be able to just read whatever the current wrapped value for the property wrapper is.

0:21:40.7 DW: So let's do this step by step. We can create a hosting environment using a bunch of ugly code. We have a function here that will host any SwiftUI view. (I promised no generic. I lied, sorry!) We create an app which is just a UIViewController or a UIHostingViewController for our SwiftUI view. We add this hosting view controller to the view controller we just made as the app. We add the subview, we set up constraints. I might be wondering, if you're not gonna put this on screen, are you? This is gonna be a unit test. Well, SwiftUI is smart. It knows that if a view's frame is zero by zero pixels, nobody will ever see it. So it's like why set up anything for that view? It has to have a non-zero size frame. So that's why you have to do this. You also have to do this, tell it to do a little bit of layout. And then a view would be actually fully set up. So we create a simple view to test. This view does not have a list in his body because we don't really care about whether the list looks good. We just care about the property wrapper because that's what we're trying to test.

0:22:47.5 DW: And then we write a test, just a simple test class, func testRemoteDataIsEventuallyAvailable(); make a view; we add the URL session to the environment we hosted, and that's when the problems start: because we do have a view now and we apply the .environment view modifier to it, which means that we no longer have an instance of SampleV iew because the environment modifier returns some view. So we threw all of the information we had about the view out the window, we just know that it's anything that would confirm to view.

0:23:22.9 DW: We can't make the view first, then access the property wrapper and then apply the environmentObject view modifier. Because we're dealing with value types, which means that everytime we pass them around or mutate them, we get copies. So we can't do that. So we should add something to the sample view that has reference semantics, which means that we add it to the view, it's gotta be an instance of a class, and then we can access that in our test. That doesn't make a ton of sense.

0:23:57.9 DW: Here's what we're looking for: our unit test needs to create the sample view; and the sample view should contain some reference type that we can use. Whenever this sample view changes, it should tell that reference type that something happened. And then the unit test can observe that reference type, which is a job for a Combine. So we add a little bit of Combine to a review. So we still have that struct with remote data. We have a pass through subject that's a Combine object. It is a class, which means that it's a reference type, which means that we can just grab it whenever we want and whenever the view appears, we will send the initial value for our wrapped value for our property wrapper over it. And whenever that feed changes, we will send the new value. So that allows us to kick off work on the property wrapper and basically know when something changed and expect a new value.

0:24:44.9 DW: So let's look at the full test. So we define a test glass and we create a sample test view in our test function. We make a test expectation because this of course should be somewhat asynchronous. We create a .sink on the results subject that allows us to receive all the values published by the view. And we check that eventually at some point the post object is no longer empty. It's a bit of a simple test. We don't care that at first it is empty and later gets a value. If it's at some point not empty, I'm okay with that. Then we can host our view and set up the environment, and we wait for expectations. And that actually allows us to test this property wrapper. It's pretty weird that we have to do this, but with a little bit of creativity and a little bit of messing around... And again, I'm showing you this in a couple of minutes, this took me a very long time to figure out. But it works, and it allows us to test our SwiftUI property wrapper, which is very important if you integrated this in your apps.

0:25:49.9 DW: So in summary, we can build custom properties or in property wrappers for SwiftUI with DynamicProperty. You can tap into the environment, which opens up a lot of possibilities: you don't have to read environment keys that you defined, you can also take things that are already in the SwiftUI environment, which can be really, really powerful. To test a custom property wrapper, you need to set up a view. And that view needs to have its view environments and everything set up. So it has to think that it's actually gonna be drawn, so that it does all the work that a view needs to do to actually work. And what's really cool with testing this is that a Combine Subject is a very nice way to get a reference type from the view that you can publish changes on, so that you can observe those changes and know what's happening inside of the property wrapper. And yes, you can make it work as an AsyncSequence, I just chose to not have too many technologies in one talk. Thank you for listening.

[applause]

0:26:47.8 DW: And if you're interested in seeing the code I just showed for that remote data or property wrapper, you can scan this code or go to my GitHub and find SwiftUI property wrapper talk there to see this in action and re-watch all the code 'cause it's a lot. Thanks.

0:27:11.0 Greg: Thank you Donny. Let's move on to the...

0:27:15.8 DW: To the fancy chairs.

0:27:21.2 Greg: I saw a um one of your slides.

0:27:22.6 DW: Did I?

0:27:22.9 Greg: Yeah, written.

0:27:24.1 DW: Oh yeah, I even made them all say it. I wonder how many ums were detected by the machine learning algorithm, it should be about 250.

[laughter]

0:27:31.3 Simone: There are lots of questions by the audience. So let's start with the last one which is, is there any way to handle errors with remote data property wrapper?

0:27:46.4 DW: Yes, so I currently made this very simple, so remote data creates an array of feed as its wrappedValue. You could make that a Result where the .success case is wrapped value or the post array and the .failure case is whatever you want it to be. And then you could switch on that in your view. So it would just mean making the output of the property wrapper result, and you'd be good to go.

0:28:11.3 Simone: Same thing for canceling a request? I guess.

0:28:14.6 DW: Yeah. So if you would wanna do that, you would need to use that _underscore prefix first with a property wrapper, that will get you the instance of remoteData, and if that has a .cancel function, you could just call it. You could also go ahead and say, well, I don't want to expose that functionality per se in that manner, you could use the projected value, for example, for that. So there's multiple ways that you can expose a cancel function.

0:28:40.5 Greg: Another question from the audience, could you make the ObservaleObject conform to DynamicProperty and solve the optionals?

0:28:50.3 DW: More or less, I guess you could do that because like I mentioned, it doesn't have to be a property wrapper, so the observed property itself could be a dynamic property too, and you could read things for the environment that way. Is that good? I haven't tried, but I'm 99% sure it would work.

0:29:07.5 Simone: Actually, it's by Antoine, so I guess you can talk that in private.

0:29:11.8 DW: I'll find you Antoine.

[laughter]

0:29:18.7 Simone: How do you maintain that? So the thing is, when you have lots of property wrappers, it might be hard for people to understand what property wrappers are being implemented and what are not. And back in the days Objective-C we had those extensions at some point. Projects had lots of those extensions and nobody was knowing what extension were available and what extension were not.

0:29:44.6 DW: Yeah. So this... Of course, you have to coordinate with your team, like which ones are we gonna write. And if you do it smartly, your property wrapper is similar to what we ended up with in the talk. They really don't do much, they just provide an interface on top of a data loader or maybe a whole suite of objects, which means that you're not reimplementing logic anyway. But yeah, it's really important that you make sure that with your team you decide which property wrappers you write, communicate which ones you have, and of course, make sure that everybody uses that. So things like poor request reviews are very helpful there. I also notices that you can go overboard with extensions, for example, you could write tons and tons of extensions, but just write ones that makes sense, ones that you actually need.

0:30:28.2 Greg: And maybe a more open question to conclude it, one that we ask ourselves every time after WWDC. Is Combine dead?

[laughter]

0:30:42.0 DW: No, no. That's just the short answer. The long answer is: for tasks, for getting data for example, definitely async await is the way to go. It has a clear start and end. It's literally like a task to fetch data. If you're observing states, the things become a lot harder and Tunde actually showed on his slide today that you have to manage cancellation yourself, while with Combine, you have your cancellables and it's all managed for you, and the default is: it doesn't work unless you retain a cancellable, the opposite is true, if you wanna observe state in async await. So if you start iterating over an async for-loop. That loop will remain active until you cancel it, so if you start this in the view controller, view controller goes off-screen, task is still active, and you have to make sure that you retain the task and cancel it, in viewDidDisapper or deinit... very error-prone. So there's definitely some ergonomic issues, in my opinion, around a complete Combine replacement, who knows? Maybe Swift 5.8 or 6 or whatever fixes that.

0:31:43.6 Simone: Great. Thank you.

0:31:44.6 DW: Thank you very much.

Edit on GitHub