5

Code

I have the following code:

struct CustomTabView: View where Content: View {

    let children: [AnyView]

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content

        let m = Mirror(reflecting: content())
        if let value = m.descendant("value") {
            let tupleMirror = Mirror(reflecting: value)
            let tupleElements = tupleMirror.children.map({ AnyView($0.value) }) // ERROR
            self.children = tupleElements
        } else {
            self.children = [AnyView]()
        }
    }

    var body: some View {
        ForEach(self.children) { child in
            child...
        }
    }
}

Problem

I'm trying to convert the TupleView into an array of AnyView but I'm receiving the error

Protocol type 'Any' cannot conform to 'View' because only concrete types can conform to protocols

Possible solution

One way I can solve this is to pass in type erased views into CustomTabView like so:

CustomTabView {
    AnyView(Text("A"))
    AnyView(Text("B"))
    AnyView(Rectangle())
}

Ideally

but I'd like to be able to do the following just like the native TabView

CustomTabView {
    Text("A")
    Text("B")
    Rectangle()
}

So how would I go about converting the TupleView into an array of AnyView?

7
  • 1
    What is the reason for using Mirror here? Commented Jan 6, 2020 at 8:50
  • 1
    The reflection on content is to get the value property of content (See here for more information about the value property). The reflection on value (a TupleView) is for iterating over the tuple. Commented Jan 6, 2020 at 16:17
  • 1
    @youjin I am trying to do the exact same thing, did you find a solution for this ? Commented Feb 26, 2020 at 0:49
  • @Brett no, I did not find a solution to this, so I went about another way to create a custom tab view. I'm not sure if it's the right way, but if you'd like to see it, let me know and I can post it! Commented Feb 26, 2020 at 2:21
  • 2
    @Brett pay me :-) Jk, I've posted it! Let me know what you think Commented Feb 26, 2020 at 5:10

3 Answers 3

7

Here's how I went about creating a custom tab view with SwiftUI:

struct CustomTabView<Content>: View where Content: View {

    @State private var currentIndex: Int = 0
    @EnvironmentObject private var model: Model

    let content: () -> Content

    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }

    var body: some View {

        GeometryReader { geometry in
            return ZStack {
                // pages
                // onAppear on all pages are called only on initial load
                self.pagesInHStack(screenGeometry: geometry)
            }
            .overlayPreferenceValue(CustomTabItemPreferenceKey.self) { preferences in
                // tab bar
                return self.createTabBar(screenGeometry: geometry, tabItems: preferences.map {TabItem(tag: $0.tag, tab: $0.item)})
            }
        }
    }

    func getTabBarHeight(screenGeometry: GeometryProxy) -> CGFloat {
        // https://medium.com/@hacknicity/ipad-navigation-bar-and-toolbar-height-changes-in-ios-12-91c5766809f4
        // ipad 50
        // iphone && portrait 49
        // iphone && portrait && bottom safety 83
        // iphone && landscape 32
        // iphone && landscape && bottom safety 53
        if UIDevice.current.userInterfaceIdiom == .pad {
            return 50 + screenGeometry.safeAreaInsets.bottom
        } else if UIDevice.current.userInterfaceIdiom == .phone {
            if !model.landscape {
                return 49 + screenGeometry.safeAreaInsets.bottom
            } else {
                return 32 + screenGeometry.safeAreaInsets.bottom
            }
        }
        return 50
    }

    func pagesInHStack(screenGeometry: GeometryProxy) -> some View {

        let tabBarHeight = getTabBarHeight(screenGeometry: screenGeometry)
        let heightCut = tabBarHeight - screenGeometry.safeAreaInsets.bottom
        let spacing: CGFloat = 100 // so pages don't overlap (in case of leading and trailing safetyInset), arbitrary

        return HStack(spacing: spacing) {
            self.content()
                // reduced height, so items don't appear under tha tab bar
                .frame(width: screenGeometry.size.width, height: screenGeometry.size.height - heightCut)
                // move up to cover the reduced height
                // 0.1 for iPhone X's nav bar color to extend to status bar
                .offset(y: -heightCut/2 - 0.1)
        }
        .frame(width: screenGeometry.size.width, height: screenGeometry.size.height, alignment: .leading)
        .offset(x: -CGFloat(self.currentIndex) * screenGeometry.size.width + -CGFloat(self.currentIndex) * spacing)
    }

    func createTabBar(screenGeometry: GeometryProxy, tabItems: [TabItem]) -> some View {

        let height = getTabBarHeight(screenGeometry: screenGeometry)

        return VStack {
            Spacer()
            HStack(spacing: screenGeometry.size.width / (CGFloat(tabItems.count + 1) + 0.5)) {
                Spacer()
                ForEach(0..<tabItems.count, id: \.self) { i in
                    Group {
                        Button(action: {
                            self.currentIndex = i
                        }) {
                            tabItems[i].tab
                        }.foregroundColor(self.currentIndex == i ? .blue : .gray)
                    }
                }
                Spacer()
            }
            // move up from bottom safety inset
            .padding(.bottom, screenGeometry.safeAreaInsets.bottom > 0 ? screenGeometry.safeAreaInsets.bottom - 5 : 0 )
            .frame(width: screenGeometry.size.width, height: height)
            .background(
                self.getTabBarBackground(screenGeometry: screenGeometry)
            )
        }
        // move down to cover bottom of new iphones and ipads
        .offset(y: screenGeometry.safeAreaInsets.bottom)
    }

    func getTabBarBackground(screenGeometry: GeometryProxy) -> some View {

        return GeometryReader { tabBarGeometry in
            self.getBackgrounRectangle(tabBarGeometry: tabBarGeometry)
        }
    }

    func getBackgrounRectangle(tabBarGeometry: GeometryProxy) -> some View {

        return VStack {
            Rectangle()
                .fill(Color.white)
                .opacity(0.8)
                // border top
                // https://www.reddit.com/r/SwiftUI/comments/dehx9t/how_to_add_border_only_to_bottom/
                .padding(.top, 0.2)
                .background(Color.gray)

                .edgesIgnoringSafeArea([.leading, .trailing])
        }
    }
}

Here's the preference and view extensions:

// MARK: - Tab Item Preference
struct CustomTabItemPreferenceData: Equatable {
    var tag: Int
    let item: AnyView
    let stringDescribing: String // to let preference know when the tab item is changed
    var badgeNumber: Int // to let preference know when the badgeNumber is changed


    static func == (lhs: CustomTabItemPreferenceData, rhs: CustomTabItemPreferenceData) -> Bool {
        lhs.tag == rhs.tag && lhs.stringDescribing == rhs.stringDescribing && lhs.badgeNumber == rhs.badgeNumber
    }
}

struct CustomTabItemPreferenceKey: PreferenceKey {

    typealias Value = [CustomTabItemPreferenceData]

    static var defaultValue: [CustomTabItemPreferenceData] = []

    static func reduce(value: inout [CustomTabItemPreferenceData], nextValue: () -> [CustomTabItemPreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

// TabItem
extension View {
    func customTabItem<Content>(@ViewBuilder content: @escaping () -> Content) -> some View where Content: View {
        self.preference(key: CustomTabItemPreferenceKey.self, value: [
            CustomTabItemPreferenceData(tag: 0, item: AnyView(content()), stringDescribing: String(describing: content()), badgeNumber: 0)
        ])
    }
}

// Tag
extension View {
    func customTag(_ tag: Int, badgeNumber: Int = 0) -> some View {

        self.transformPreference(CustomTabItemPreferenceKey.self) { (value: inout [CustomTabItemPreferenceData]) in

            guard value.count > 0 else { return }
            value[0].tag = tag
            value[0].badgeNumber = badgeNumber

        }
        .transformPreference(CustomTabItemPreferenceKey.self) { (value: inout [CustomTabItemPreferenceData]) -> Void in

            guard value.count > 0 else { return }
            value[0].tag = tag
            value[0].badgeNumber = badgeNumber
        }
        .tag(tag)
    }
}

and here's the usage:

struct MainTabsView: View {
    var body: some View {
        // TabView
        CustomTabView {
            A()
                .customTabItem { ... }
                .customTag(0, badgeNumber: 1)
            B()
                .customTabItem { ... }
                .customTag(2)
            C()
                .customTabItem { ... }
                .customTag(3)
        }
    }
}

I hope that's useful to y'all out there, let me know if you know a better way!

Sign up to request clarification or add additional context in comments.

1 Comment

The use of the Preferences to pass info back to the container is brilliant. It looks like a lot of work went into this. Thanks a lot for posting the complete solution, I am sure this is going to be useful to many people. The cheque is in the mail @youjin ;-)
2

I created IterableViewBuilder for this purpose

struct ContentView: View {
...

  init<C: IterableView>(@IterableViewBuilder content: () -> C) {
    let count = content().count
    content().iterate(with: Visitor())
  }
}

struct Visitor: IterableViewVisitor {
  func visit<V>(_ value: V) where V : View {
    print("value")
  }
}
...

ContentView {
  Text("0")
  Text("1")
}

Comments

1

Actually, there's a native approach to accomplish this via the _VariadicView.MultiViewRoot or _VariadicView.UnaryViewRoot protocols.

Using internal APIs is generally discouraged because they can change or be removed in future updates, but it's the only approach that works well.

You need to implement one of these protocols. Then, in the body(children: _VariadicView.Children) -> some View method, you can iterate through all subviews (_VariadicView.Children is a Collection). Your implementation and children should be wrapped with the _VariadicView.Tree view.

The choice between the two depends on your needs: if you want a view that behaves like an array of views and can be placed in a VStack or HStack etc, you would use _VariadicView.MultiViewRoot. On the other hand, if you need a single standalone view, _VariadicView.UnaryViewRoot would be the way to go.

Here's an example:

public struct WithSeparator<Separator: View>: ViewModifier {

    public var separator: Separator

    public func body(content: Content) -> some View {
        _VariadicView.Tree(Root(base: self)) {
            content
        }
    }

    private struct Root: _VariadicView.MultiViewRoot {

        let base: WithSeparator
        @Environment(\.separatorLocation)
        private var separatorLocation

        func body(children: _VariadicView.Children) -> some View {
            if !children.isEmpty {
                if separatorLocation.contains(.start) {
                    base.separator
                }
                ForEach(Array(children.dropLast())) { child in
                    child
                    if separatorLocation.contains(.between) {
                        base.separator
                    }
                }
                children[children.count - 1]
                if separatorLocation.contains(.end) {
                    base.separator
                }
            }
        }
    }
}

public extension View {

    func separator(@ViewBuilder _ separator: () -> some View) -> some View {
        modifier(WithSeparator(separator: separator()))
    }
}

This code creates a WithSeparator struct that uses the _VariadicView.MultiViewRoot protocol. This allows you to add a separator between your views.

VStack {
   ForEach(...) { 
      ...
   }.separator {
      Color.black.frame(height: 1)
   }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.