rundfunk47 / stinsen

Coordinators in SwiftUI. Simple, powerful and elegant.
MIT License
923 stars 98 forks source link

Using a Custom TabBar #42

Closed Goktug closed 4 months ago

Goktug commented 2 years ago

In my project, I need to calculate the tab height. However, there is no way to access the existing TabBar in your library. If you can add the height value as an environment variable, would be nice. On the other hand, I am thinking that if you can add an ability to use Custom Tab Bar would be awesome.

What do you think? I'd like to hear your opinions

YuantongL commented 2 years ago

+1

@Goktug I personally have a hack to achieve this, Overriding func view() -> AnyView function in TabCoordinatable gives you opportunity to use your own custom tab bar, you can create one using UIKit or use a pure SwiftUI implementation as you like.

To make it all working, this custom tab bar should mimic the TabCoordinatableView, basically follow its init, this is where the hack comes in, I have to use a @testable import Stinsen to access things like coordinator.child.allItems, and its presentables.

Expose these internal variables to public will do it, but a nicer way is - we can pass in our own ViewBuilder for tab bar and their associated views.

Goktug commented 2 years ago

Thanks for the advice @YuantongL, I was also trying to hack but couldn't reach the active tab index which is reactive. At least, if the library could provide this value we don't need to use @testable hack

YuantongL commented 2 years ago

@Goktug I think https://github.com/rundfunk47/stinsen/pull/43 this is all we need in order to create a customized tab bar and tab view.

Goktug commented 2 years ago

@YuantongL I found a way without too much hacking.

First, you need to hide the UITabBar globally.

UITabBar.appearance().isHidden = true

And then you need to create an environment variable to pass active tab value to inside through subviews

struct ActiveTab: EnvironmentKey {
  static let defaultValue: Int = 0
}

we need to use the customize method of the coordinator to be able to place our custom tab bar and then we need to pass activeTab value to be able to make our custom tab bar fully functional

  @ViewBuilder func customize(_ view: AnyView) -> some View {
    ZStack(alignment: .bottom) {
      view
      CustomTabBarView() // <-- This is our custom tab bar
    }
    .environment(\.activeTab, self.child.activeTab)
  }

Every time user changed the tab, we need to pass the new active tab index value. Therefore, we'll be using tab item creating @ViewBuilder methods

  @ViewBuilder func makeHomeTab(isActive: Bool) -> some View {
      EmptyView()
        .environment(\.activeTab, self.child.activeTab)
  }

Almost everything is done, the only thing that we need to do is, handling the navigation when user click a specific custom tab button

  Button {
    _ = router <-- Injecting coordinator router
      .focusFirst(\.home)
      .child
  } label: {
     // activeTab == 0 <-- You can change the UI with checking the active tab index value
     // Tab Item UI omitted
  }

Finally, I managed to create a custom tab bar using this structure without hacking the library. I hope I would be helpful to you as well

YuantongL commented 2 years ago

@Goktug Thanks, that's a nice approach, definitely better then the hack!

I made similar change to my project, the only difference is, instead of pass in environment variable, I made it through a binding.

struct CustomTabBarView: View {
    @Binding
    var activeTabIndex: Int
    var body: some View {
        HStack {
            Button {
                activeTabIndex = 0
            } label: {
                Text("Tab 0")
            }
            Button {
                activeTabIndex = 1
            } label: {
                Text("Tab 1")
            }
    }
}

Then in the coordinator, do the following

    private var activeTabIndex = 0 {
        didSet {
            child.activeTab = activeTabIndex
        }
    }

    @ViewBuilder func customize(_ view: AnyView) -> some View {
        ZStack(alignment: .bottom) {
            view
            CustomTabBarView(activeTabIndex: .init(get: {
                self.activeTabIndex
            }, set: { newValue in
                self.activeTabIndex = newValue
            }))
        }
    }
Goktug commented 2 years ago

Your approach only relies on tab clicks, if you want to navigate through tabs via the router, your approach will fail. E.g. deep link. WDYT?

rundfunk47 commented 2 years ago

Hi! Instead of a TabCoordinatable, a NavigationCoordinatable can also be used with your previous workaround @Goktug. Then you don't need to hide the tabbar globally - and instead of switching the tab you can use the setRoot-function. I could whip up an example if things are still unclear later...

YuantongL commented 2 years ago

@Goktug my approch doesn't have to depend on tab clicks if I simply remove the private activeTabIndex and use

    @ViewBuilder func customize(_ view: AnyView) -> some View {
        ZStack(alignment: .bottom) {
            view
            CustomTabBarView(activeTabIndex: .init(get: {
                self.child.activeTab
            }, set: { newValue in
                self.child.activeTab = newValue
            }))
        }
    }

Anything that has a TabCoordinator.Router can change tab via focusFirst and our custom tab bar changes accordingly (since we are getting the index value from child.activeTab). I think both approach works, it is just personally I prefer pass around a Binding in this case instead of environment object.

2jumper3 commented 1 year ago

How

@Goktug my approch doesn't have to depend on tab clicks if I simply remove the private activeTabIndex and use

    @ViewBuilder func customize(_ view: AnyView) -> some View {
        ZStack(alignment: .bottom) {
            view
            CustomTabBarView(activeTabIndex: .init(get: {
                self.child.activeTab
            }, set: { newValue in
                self.child.activeTab = newValue
            }))
        }
    }

Anything that has a TabCoordinator.Router can change tab via focusFirst and our custom tab bar changes accordingly (since we are getting the index value from child.activeTab). I think both approach works, it is just personally I prefer pass around a Binding in this case instead of environment object.

Hi! How you resolve problem if u need to hide tabView? Custom tab view always showing if u use this method ) photo_2023-01-11 11 21 40

alvin-7 commented 9 months ago

How

@Goktug my approch doesn't have to depend on tab clicks if I simply remove the private activeTabIndex and use

    @ViewBuilder func customize(_ view: AnyView) -> some View {
        ZStack(alignment: .bottom) {
            view
            CustomTabBarView(activeTabIndex: .init(get: {
                self.child.activeTab
            }, set: { newValue in
                self.child.activeTab = newValue
            }))
        }
    }

Anything that has a TabCoordinator.Router can change tab via focusFirst and our custom tab bar changes accordingly (since we are getting the index value from child.activeTab). I think both approach works, it is just personally I prefer pass around a Binding in this case instead of environment object.

Hi! How you resolve problem if u need to hide tabView? Custom tab view always showing if u use this method ) photo_2023-01-11 11 21 40

Did you manage to solve this problem later on? If so, how did you do it?

2jumper3 commented 9 months ago

Did you manage to solve this problem later on? If so, how did you do it?

Hi @alvin-7 ! Yes, remove everything and create TabBar with UIKit :-)

jedmund commented 5 months ago

@2jumper3 Can you go into a bit more detail on how you removed the existing tab bar? I have a tab bar implementation I want to use that I'm trying to figure out the best way to implement. Thanks!

pulimento commented 4 months ago

@2jumper3 Can you go into a bit more detail on how you removed the existing tab bar? I have a tab bar implementation I want to use that I'm trying to figure out the best way to implement. Thanks!

this comment worked for me to implement a view that replaces the tabview with something custom

est7 commented 4 months ago

first of all,set hide UITabBar in your app:

UITabBar.appearance().isHidden = true

then:


enum Tab: Int {
  case home = 0
  case chat, profile
}

final class AppCoordinator: TabCoordinatable {
  var child: Stinsen.TabChild = TabChild(
    startingItems: [
      \AppCoordinator.homePage,
      \AppCoordinator.chatPage,
      \AppCoordinator.profilePage,
    ])

  private var activeTab: Tab = .home {
    didSet {
      self.child.activeTab = activeTab.rawValue
    }
  }

  @ViewBuilder
  func customize(_ view: AnyView) -> some View {
    ZStack(alignment: .bottom) {
      view
      BottomNavigationBar(
        selectedTab: .init(
          get: {
            return Tab(rawValue: self.child.activeTab) ?? .home
          },
          set: { newValue in
            self.activeTab = newValue
          }
        )
      )
    }
  }

  @Route(tabItem: makeHomeTabItem)
  var homePage = makeHomeCoordinator

  @Route(tabItem: makeChatTabItem)
  var chatPage = makeChatCoordinator

  @Route(tabItem: makeProfileTabItem)
  var profilePage = makeProfileCoordinator

  @ViewBuilder
  func makeHomeTabItem(isActive: Bool) -> some View {
    // Actually it won't be shown here
    Label("Home", systemImage: "house")
  }

  @ViewBuilder
  func makeChatTabItem(isActive: Bool) -> some View {
    Label("Chat", systemImage: "message")
  }

  @ViewBuilder
  func makeProfileTabItem(isActive: Bool) -> some View {
    Label("Profile", systemImage: "person")
  }
}

my BottomNavigationBar:

 struct BottomNavigationBar: View {
    @Binding var selectedTab: Tab

    var body: some View {
        HStack {
            BottomNavigationTabItem(icon: "house", title: "Home", tab: .home, selectedTab: $selectedTab)
            Spacer()
            BottomNavigationTabItem(icon: "message", title: "Chat", tab: .chat, selectedTab: $selectedTab)
            Spacer()
           BottomNavigationTabItem(icon: "person", title: "Me", tab: .profile, selectedTab: $selectedTab)
        }
        .padding()
        .background(
            BlurView(style: .systemMaterial)
                .clipShape(RoundedRectangle(cornerRadius: 35.0, style: .continuous))
        )
        .padding(.horizontal,20)
        .padding(.bottom, 10)
    }
}