Suppose I have some view that is dependent on asynchronous data throughout my app. I want to create a "Placeholder" for that view, similar to Facebook, X, or other platforms, showing a .redacted
view while the content is loading. Here is how it's usually done.
struct Content: View {
@State var asyncText: String? = nil
var body: some View {
Group {
if let asyncText {
Text(asyncText)
} else {
Text("Placeholder Text")
.redacted(reason: .placeholder)
}
}
.task({ asyncText = try await someTask() })
}
}
This works great, in most cases, however that can get cumbersome with complicated and complex views. Is there some way to generically skeletonize a view without having to put conditions everywhere?
Suppose I have some view that is dependent on asynchronous data throughout my app. I want to create a "Placeholder" for that view, similar to Facebook, X, or other platforms, showing a .redacted
view while the content is loading. Here is how it's usually done.
struct Content: View {
@State var asyncText: String? = nil
var body: some View {
Group {
if let asyncText {
Text(asyncText)
} else {
Text("Placeholder Text")
.redacted(reason: .placeholder)
}
}
.task({ asyncText = try await someTask() })
}
}
This works great, in most cases, however that can get cumbersome with complicated and complex views. Is there some way to generically skeletonize a view without having to put conditions everywhere?
Share Improve this question edited Mar 25 at 5:27 HangarRash 15.1k5 gold badges20 silver badges55 bronze badges asked Mar 25 at 4:45 xTwisteDxxTwisteDx 2,4801 gold badge12 silver badges35 bronze badges1 Answer
Reset to default 0If you've ever been in a situation, where you want to generically skeletonize a view your options have largely been limited to complex conditions, or using pre-built libraries. However, there is a way to do it properly. There are several pieces to the puzzle that you'll need to solve first and foremost.
How does .redacted
actually work?
- Redacted simply takes the frames of text, images, and various other views and applies a layer over top of that content, when the modifier is explicitly defined. In the case of the original question, this is why you have to provide a replica of that
Text
with its modifiers, to get that field to size itself properly and in an expected manner.
How do we avoid having to copy data everywhere, or duplicate the view?
- This is where a wonderful idea of
"Mocking"
comes into play. Surely you've used mocked data in your previews all of the time, and you're probably wishing you could somehow use that mock data, for your skeletons, without replacing your views. If you're reading carefully, you'll begin to notice our solution is answering itself. The question mentionsGenerics
, not replacing your views hints at the other part that we need to implement, data-swapping. We need a way to not only provide mock data, but after it's loaded swap to real data. Sure you could simply initialize the data straight away, but your views will have no context on "When should I show "Real" data." And thus the solution presents itself.
The solution
First, Mocking
Initially we want to mock any data that might be consumed within our apps. A common way that I've always done this is to use a protocol, colloquially called Mockable
protocol Mockable {
associatedtype MockType
static var mock: MockType { get }
static var mockList: [MockType] { get }
}
extension Mockable {
static var mockList: [MockType] { [] }
}
Looking at this protocol, effectively what it does for us is define conformance to it. So if we had some class Car
and conformed to Mockable
then we have to provide some initialized value for the .mock
and .mockList
. I've added an extension for the .mockList
purely to keep me from having to continually use it everywhere. Conformance might look like this.
struct Car: Mockable {
let wheels: Int
static var mock: Car {
.init(wheels: 4)
}
}
So when we conform to this protocol, we're going to provide some data, we can use this data either in our #Previews
or in our upcoming solution. There's probably a really good chance that you've already got some way of mocking data, but for this use-case, we actually need a protocol.
The Generic handler!
You want some way to not only initialize your data with "Dummy" information, but also know whether you're using "MOCK" or "REAL" data at any given moment, but also being fully state-compatible. So the structure I've visualized is to use something akin to let someValue: Framed<String>
. When we specify String
you're effectively telling the generic, Framed, that hey, our underlying type is String
which is great. This gives us a way to actually set a real value, or a mock value, and also know the truth on the view. Its implementation looks like this.
enum DataState {
case real
case mock
}
struct Framed<T> {
let value: T
let state: DataState
static func real(_ value: T) -> Framed<T> {
Framed(value: value, state: .real)
}
// For single values
static func mock<M: Mockable>(using mockable: M.Type) -> Framed<M.MockType> where T == M.MockType {
Framed(value: mockable.mock, state: .mock)
}
// For arrays
static func mock<M: Mockable>(using mockable: M.Type) -> Framed<[M.MockType]> where T == [M.MockType] {
let mockedArray = Array(repeating: mockable.mock, count: 5)
return Framed(value: mockedArray, state: .mock)
}
var isMock: Bool {
state == .mock
}
}
What is actually happening with this? Well, when we initialize the value, going back to our car example, it might look something like this.
@State var car: Framed<Car> = .mock(using: Car.self)
Remember that in our Framed<T>
struct, we've defined a computed property isMock
when we initialize the object we're giving it fake-data, but also giving the object context saying that this is indeed fake.
So um... what now?
Well, you're now at the finish line. Going back to the question. Normally you'd have a condition, then replicate the view, then update state to get your skeletonization. Instead now here's how it'd actually look.
struct Content: View {
@State var car: Framed<Car> = .mock(using: Car.self)
var body: some View {
Text(car.value.wheels)
.redacted(reason: car.isMock ? .placeholder : [])
.task({ asyncText = .real(try await someTask()) })
}
}
This simple setup, wrapping your structs/objects with Framed
immediately removes any duplicated views, for the sake of a redacted view. It also responds to state as you'd expect. Notice that the async call sets the data using .real(...)
which then updates the DataState
to .real
. Now you've got a generic way of handling placeholder views.