Is there any way to create/extract an array of Views using @ViewBuilder in SwiftUI
Asked Answered
B

3

25

I'm trying to create a simple struct that accepts an array of Views and returns an ordinary VStack containing those Views except they all are stacked diagonally.

Code:

struct LeaningTower<Content: View>: View {
    var views: [Content]
    var body: some View {
        VStack {
            ForEach(0..<views.count) { index in
                self.views[index]
                    .offset(x: CGFloat(index * 30))
            }
        }
    }
}

Now this works great but I always get annoyed whenever I have to call it:

LeaningTower(views: [Text("Something 1"), Text("Something 2"), Text("Something 3")])

Listing the views in an array like that seems extremely odd to me so I was wondering if there was a way I could use @ViewBuilder to call my LeaningTower struct like this:

LeaningTower {  // Same way how you would create a regular VStack
    Text("Something 1")
    Text("Something 2")
    Text("Something 3")
    // And then have all of these Text's in my 'views' array
}

If there's a way to use @ViewBuilder to create/extract an array of Views please let me know.

(Even if it isn't possible using @ViewBuilder literally anything that will make it look neater will help out a lot)

Billet answered 4/7, 2020 at 14:31 Comment(0)
C
21

It's rare that you need to extract views from an array. If you are just looking to pass @ViewBuilder content into a view, you can simply do the following:

struct ContentView: View {
    var body: some View {
        VStackReplica {
            Text("1st")
            Text("2nd")
            Text("3rd")
        }
    }
}

struct VStackReplica<Content: View>: View {
    @ViewBuilder let content: () -> Content

    var body: some View {
        VStack(content: content)
    }
}

If this isn't sufficient for your use-case, then see below.


Recommended: use my ViewExtractor package as it's much more robust than the code here.

I have got a generic version working, so there is no need to make multiple initializers for different lengths of tuples. In addition, the views can be anything you want (you are not restricted for every View to be the same type).

You can find a Swift Package I made for this at GeorgeElsham/ViewExtractor. That contains more than what's in this answer, because this answer is just a simplified & basic version. Since the code is slightly different to this answer, so read the README.md first for an example.

Back to the answer, example usage:

struct ContentView: View {
    
    var body: some View {
        LeaningTower {
            Text("Something 1")
            Text("Something 2")
            Text("Something 3")
            Image(systemName: "circle")
        }
    }
}

Definition of your view:

struct LeaningTower: View {
    private let views: [AnyView]
    
    init<Views>(@ViewBuilder content: @escaping () -> TupleView<Views>) {
        views = content().getViews
    }
    
    var body: some View {
        VStack {
            ForEach(views.indices) { index in
                views[index]
                    .offset(x: CGFloat(index * 30))
            }
        }
    }
}

TupleView extension (AKA where all the magic happens):

extension TupleView {
    var getViews: [AnyView] {
        makeArray(from: value)
    }
    
    private struct GenericView {
        let body: Any
        
        var anyView: AnyView? {
            AnyView(_fromValue: body)
        }
    }
    
    private func makeArray<Tuple>(from tuple: Tuple) -> [AnyView] {
        func convert(child: Mirror.Child) -> AnyView? {
            withUnsafeBytes(of: child.value) { ptr -> AnyView? in
                let binded = ptr.bindMemory(to: GenericView.self)
                return binded.first?.anyView
            }
        }
        
        let tupleMirror = Mirror(reflecting: tuple)
        return tupleMirror.children.compactMap(convert)
    }
}

Result:

Result

Contrast answered 24/4, 2021 at 13:56 Comment(5)
Hi George, that's a very interesting approach. Would you mind explaining what the makeArray function is doing?Snooty
@lenny I’m creating a Mirror of the tuple value, in TupleView. This contains all the views. Because View is generic and has an associatedtype, I can’t just cast like as? View. Instead, I bind the memory of the view to a ‘recreation’ of the structure of View, GenericView. It’s then converted to AnyView. Basically the whole function converts a tuple to an array, without problems with generics.Contrast
Hi @George! Interesting approach! From what I've read, AnyView should be avoided because it erases the type structure of its contents, making it impossible for SwiftUI to efficiently know which parts of the view tree need to be re-rendered. Do you think this would be a problem here? Or maybe only for specific use cases where there's shared state between the views passed in as content?Showing
@Showing Very good question - generally AnyView isn't too much of a problem - see here. In general if the data isn't changing much and there aren't complex animations, you'll be completely fine. Performance difference here will be negligible. What I am unsure of is if this only affects the top-level views, and not subviews (what I mean by this is if you passed in a VStack, would everything work normally inside of that?). Either way it shouldn't really be of concern, unless you got 1000 rows using this, then there may be a performance hit.Contrast
@Showing If possible, you should stick to the usually HStack, VStack, ForEach, etc which are built-in. You can still pass content into them. This extension is more for complex and rare use cases. Don't make use of this everywhere in your app.Contrast
I
14

literally anything that will make it look neater will help out a lot

Well, this will get you closer:

Add this init to your LeaningTower:

init(_ views: Content...) {
    self.views = views
}

Then use it like this:

LeaningTower(
    Text("Something 1"),
    Text("Something 2"),
    Text("Something 3")
)

You could add your offset as an optional parameter:

struct LeaningTower<Content: View>: View {
    var views: [Content]
    var offset: CGFloat
    
    init(offset: CGFloat = 30, _ views: Content...) {
        self.views = views
        self.offset = offset
    }
    
    var body: some View {
        VStack {
            ForEach(0..<views.count) { index in
                self.views[index]
                    .offset(x: CGFloat(index) * self.offset)
            }
        }
    }
}

Example usage:

LeaningTower(offset: 20,
    Text("Something 1"),
    Text("Something 2"),
    Text("Something 3")
)
Interruption answered 4/7, 2020 at 15:24 Comment(1)
What if I wanted to use different view types together?Urson
E
13

Came across this while trying to figure out something similar myself. So for the benefit of posterity and others banging their heads against similar problems.

The best way I've found to get child offsets is by using Swift's typing mechanism as follows. e.g.

struct LeaningTowerAnyView: View {
    let inputViews: [AnyView]

    init<V0: View, V1: View>(
        @ViewBuilder content: @escaping () -> TupleView<(V0, V1)>
    ) {
        let cv = content().value
        inputViews = [AnyView(cv.0), AnyView(cv.1)]
    }

    init<V0: View, V1: View, V2: View>(
        @ViewBuilder content: @escaping () -> TupleView<(V0, V1, V2)>) {
        let cv = content().value
        inputViews = [AnyView(cv.0), AnyView(cv.1), AnyView(cv.2)]
    }

    init<V0: View, V1: View, V2: View, V3: View>(
        @ViewBuilder content: @escaping () -> TupleView<(V0, V1, V2, V3)>) {
        let cv = content().value
        inputViews = [AnyView(cv.0), AnyView(cv.1), AnyView(cv.2), AnyView(cv.3)]
    }

    var body: some View {
        VStack {
            ForEach(0 ..< inputViews.count) { index in
                self.inputViews[index]
                    .offset(x: CGFloat(index * 30))
            }
        }
    }
}

Usage would be

struct ContentView: View {
    var body: some View {
        LeaningTowerAnyView {
            Text("Something 1")
            Text("Something 2")
            Text("Something 3")
        }
    }
}

It'll also work with any View and not just Text, e.g.

struct ContentView: View {
    var body: some View {
        LeaningTowerAnyView {
            Capsule().frame(width: 50, height: 20)
            Text("Something 2").border(Color.green)
            Text("Something 3").blur(radius: 1.5)
        }
    }
}

The downside is that each additional View needs a custom initialiser, but it sounds like it's the same approach that Apple is using for their stacks

I suspect something based on Mirror and persuading Swift to dynamically call a variable number of differently named methods might be made to work. But that gets scary and I ran out of time about a week ago ;-) . Would be very interested if anyone's got anything more elegant.

Ervinervine answered 19/10, 2020 at 11:33 Comment(2)
Works really well (and with mixed views) if you don't need that many views in your container.Urson
Usage of AnyView is a no-go for me. I consider AnyView as a code smell in SwiftUI.Showing

© 2022 - 2024 — McMap. All rights reserved.