Search code examples
iosswiftswiftuiswiftui-tabview

SwiftUI custom TabView with paging style


I'm trying to create a custom TabView in SwiftUI, that also has a .tabViewStyle(.page()) functionality too.

At the moment I'm 99% of the way there, but cannot figure out how to get all the TabBarItems to list.

I'm using the PreferenceKey so that the order I add them into the closure is the order in the TabView.

When I run it, the tab items are added to the array, then removed, and it doesn't seem to be working.

I had it working with the enum as CaseIterable and the ForEach(tabs) { tab in as ForEach(TabBarItems.allCases) { tab in, but as mentioned wanted the order in the bar to be organic from the clousure.

Container

struct TabViewContainer<Content : View>: View {
  @Binding private var selection: TabBarItem
  @State private var tabs: [TabBarItem] = []
  var content: Content

  init(selection: Binding<TabBarItem>, @ViewBuilder content: () -> Content) {
    self._selection = selection
    self.content = content()
  }

  var body: some View {
    ZStack(alignment: .bottom) {
      TabView(selection: $selection) {
        content
      }
      .tabViewStyle(.page(indexDisplayMode: .never))
      tabBarItems()
    }
    .onPreferenceChange(TabBarItemsPreferenceKey.self) { self.tabs = $0 }
  }

  private func tabBarItems() -> some View {
    HStack(spacing: 10) {
      ForEach(tabs) { tab in
        Button {
          selection = tab
        } label: {
          tabButton(tab: tab)
        }
      }
    }
    .padding(.horizontal)
    .frame(maxWidth: .infinity)
    .padding(.top, 8)
    .background(Color(uiColor: .systemGray6))
  }

  private func tabButton(tab: TabBarItem) -> some View {
    VStack(spacing: 0) {
      Image(icon: tab.icon)
        .font(.system(size: 16))
        .frame(maxWidth: .infinity, minHeight: 28)
      Text(tab.title)
        .font(.system(size: 10, weight: .medium, design: .rounded))
    }
    .foregroundColor(selection == tab ? tab.colour : .gray)
  }
}

PreferenceKey / Modifier

struct TabBarItemsPreferenceKey: PreferenceKey {
  static var defaultValue: [TabBarItem] = []
  static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) {
    value += nextValue()
  }
}

struct TabBarItemViewModifier: ViewModifier {
  let tab: TabBarItem
  func body(content: Content) -> some View {
    content.preference(key: TabBarItemsPreferenceKey.self, value: [tab])
  }
}

extension View {
  func tabBarItem(_ tab: TabBarItem) -> some View {
    modifier(TabBarItemViewModifier(tab: tab))
  }
}

Demo view

struct TabSelectionView: View {
  @State private var selection: TabBarItem = .itinerary
  var body: some View {
    TabViewContainer(selection: $selection) {
      PhraseView()
        .tabBarItem(.phrases)
      ItineraryView()
        .tabBarItem(.itinerary)
      BudgetView()
        .tabBarItem(.budget)
      BookingView()
        .tabBarItem(.bookings)
      PackingListView()
        .tabBarItem(.packing)
    }
  }
}
Intended Current

Solution

  • You can use a more elegant way, @resultBuilder:

    1. You create a struct that holds the View & the tag;
    2. tabBarItem should now return the previously created struct;
    3. The @resultBuilder will then build your array of your view & tag which you'll be using inside the container.

    ResultBuilder:

    @resultBuilder
    public struct TabsBuilder {
        internal static func buildBlock(_ components: Tab...) -> [Tab] {
            return components
        }
        internal static func buildEither(first component: Tab) -> Tab {
            return component
        }
        internal static func buildEither(second component: Tab) -> Tab {
            return component
        }
    }
    

    Tab:

    struct Tab: Identifiable {
        var content: AnyView //I don't recommend the use of AnyView, but I don't want to dive deep into generics for now.
        var tag: TabBarItem
        var id = UUID()
    }
    

    Modifier:

    struct Tab: Identifiable {
        var content: AnyView
        var tag: TabBarItem
        var id = UUID()
    }
    

    TabViewContainer:

    struct TabViewContainer: View {
        @Binding private var selection: TabBarItem
        @State private var tabs: [TabBarItem]
        var content: [Tab]
        init(selection: Binding<TabBarItem>, @TabsBuilder content: () -> [Tab]) {
            self._selection = selection
            self.content = content()
            self.tabs = self.content.map({$0.tag})
        }
        var body: some View {
            ZStack(alignment: .bottom) {
                TabView(selection: $selection) {
                    ForEach(content) { content in
                        content.content
                            .tag(content.tag)
                    }
                }.tabViewStyle(.page(indexDisplayMode: .never))
                tabBarItems()
            }
        }
        private func tabBarItems() -> some View {
            HStack(spacing: 10) {
                ForEach(tabs) { tab in
                    Button {
                        selection = tab
                    } label: {
                        tabButton(tab: tab)
                    }
                }
            }
            .padding(.horizontal)
            .frame(maxWidth: .infinity)
            .padding(.top, 8)
            .background(Color(uiColor: .systemGray6))
        }
        private func tabButton(tab: TabBarItem) -> some View {
            VStack(spacing: 0) {
                Image(icon: tab.icon)
                    .font(.system(size: 16))
                    .frame(maxWidth: .infinity, minHeight: 28)
                Text(tab.title)
                    .font(.system(size: 10, weight: .medium, design: .rounded))
            }
            .foregroundColor(selection == tab ? tab.colour : .gray)
        }
    }