Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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)

like image 906
rayaantaneja Avatar asked Jul 04 '20 14:07

rayaantaneja


People also ask

What does @ViewBuilder do in SwiftUI?

@ViewBuilder is a kind of result builder that's specifically designed to help create child views. Result builders create functions that build a result from a sequence of elements. SwiftUI uses this in its own native views, controls and components. It also uses this in the body to compose your views.

What is a ViewBuilder?

A custom parameter attribute that constructs views from closures.

How do I create a custom view in SwiftUI?

To get started, you'll create a new custom view to manage your map. Choose File > New > File, select iOS as the platform, select the “SwiftUI View” template, and click Next. Name the new file MapView. swift and click Create.

How do I count <UNK>ViewBuilder views in SwiftUI?

It's not possible to detect the count if individual views inside a @ViewBuilder closure. The @ViewBuilder creates one resulting view and your inPutView is treated as a single view. A possible solution is to pass the [AnyView] array as the input of ModelView .


3 Answers

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.


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

like image 184
George Avatar answered Oct 08 '22 22:10

George


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.

like image 31
shufflingb Avatar answered Oct 08 '22 22:10

shufflingb


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")
)
like image 9
vacawama Avatar answered Oct 08 '22 21:10

vacawama