最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

swift - How do I generically skeletonize my SwiftUI views? - Stack Overflow

programmeradmin7浏览0评论

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 badges
Add a comment  | 

1 Answer 1

Reset to default 0

If 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 mentions Generics, 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.

发布评论

评论列表(0)

  1. 暂无评论