Programmatically detect Tab Bar or TabView height in SwiftUI
Asked Answered
P

6

11

I have a SwiftUI app that will have a floating podcast player, similar to the Apple Music player that sits just above the Tab Bar and persists across all tabs and views while the player is running. I have not figured out a good way to position the player so that it is flush above the Tab Bar, since the Tab Bar height changes based on device. The main issue I've found is just how I have to position the player in an overlay or ZStack in the root view of my app, instead of within the TabView itself. Since we can't customize the view hierarchy of the TabView layout, there is no way to inject a view in-between the TabBar itself and the content of the view above it. My basic code structure:

TabView(selection: $appState.selectedTab){
  Home()
  .tabItem {
    VStack {
        Image(systemName: "house")
        Text("Home")
    }
  }
  ...
}.overlay(
  VStack {
    if(audioPlayer.isShowing) {
      Spacer()
      PlayerContainerView(player: audioPlayer.player)
      .padding(.bottom, 58)
      .transition(.moveAndFade)
    }
  })

The main issue here is that the location of the PlayerContainerView is hard-coded with a padding of 58 so that it clears the TabView. If I could detect the actual frame height of the TabView, I could globally adjust this for the given device and I would be fine. Does anybody know how to do this reliably? Or do you have any idea how I could place the PlayerContainerView within the TabView itself so that it simply appears BETWEEN the Home() view and the Tab Bar when it is toggled to show? Any feedback would be appreciated.

Pyrosis answered 29/1, 2020 at 15:3 Comment(3)
did you try with GeometryReader?Mastaba
Yes, but I could not figure out how to target the TabView...or at least I was unable to find a relevant height value that actually corresponds to the drawn height of the tab bar for that specific device.Pyrosis
@Pyrosis I tried it, you can see how it works in my answer ...Solvency
P
20

As bridge to UIKit is officially allowed and documented, it is possible to read needed information from there when needed.

Here is possible approach to read tab bar height directly from UITabBar

// Helper bridge to UIViewController to access enclosing UITabBarController
// and thus its UITabBar
struct TabBarAccessor: UIViewControllerRepresentable {
    var callback: (UITabBar) -> Void
    private let proxyController = ViewController()

    func makeUIViewController(context: UIViewControllerRepresentableContext<TabBarAccessor>) ->
                              UIViewController {
        proxyController.callback = callback
        return proxyController
    }
    
    func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<TabBarAccessor>) {
    }
    
    typealias UIViewControllerType = UIViewController

    private class ViewController: UIViewController {
        var callback: (UITabBar) -> Void = { _ in }

        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
            if let tabBar = self.tabBarController {
                self.callback(tabBar.tabBar)
            }
        }
    }
}

// Demo SwiftUI view of usage
struct TestTabBar: View {
    var body: some View {
        TabView {
            Text("First View")
                .background(TabBarAccessor { tabBar in
                    print(">> TabBar height: \(tabBar.bounds.height)")
                    // !! use as needed, in calculations, @State, etc.
                })
                .tabItem { Image(systemName: "1.circle") }
                .tag(0)
            Text("Second View")
                .tabItem { Image(systemName: "2.circle") }
                .tag(1)
        }
    }
}
Poulter answered 29/1, 2020 at 17:34 Comment(8)
This works, however the value it gives me is 83 for an iPhone 11 Max Pro. The actual distance (in padding, at least) is 60 pixels. I think that your solution gives me the distance to the edge, plus the unsafe area, or something like that. The problem is that I have to space things in the app based on SwiftUI's bounds, which means I can't use this exact calculation unless I know the device specific difference between safe area and non-safe area. In this case it's 23 pixels, but in other cases it varies. Any thoughts how to deal with that?Pyrosis
Adding to that, if you programmatically compute the safe area insets for this same device, the value given is 34. However, 83 - 34 does not equal 60. So I just continue to have no idea how to actually get that number programmatically, although your answer does answer my question as stated.Pyrosis
Yes, I've been a little bit sceptic at the beginning. It took some time but I found usable solution, which works on all devices and orientations. If you like to do, see the solution in my answer.Solvency
@Pyrosis the solution exists, see my answer (even though i am late with it)Solvency
For some reason when is used in iPadOS it makes tabbar disappear (being transparent)Brasca
Is there a way to get 'tabBar.bounds.height' somewhere other than each tab?Hinduism
@Pyrosis the value tabBar.bounds.height gives is tabbar [+bottom safeArea], so by subtracting the bottom safeArea you receive the exact height of tapbar, which is 49 for most devices in non-zoomed mode. Pay attention to tapbar separator, which is rendered outside of its bounds by one pixel, so to position your view just above the tapbar you have to add 1/3 points to the padding.Giblets
@Giblets See my answer bellow. In short we can get the offset with tabBar.bounds.height - tabBar.safeAreaInsets.bottomPrimp
S
11

It seems, that you need to know the maximal size of player (size of space above tab bar), not height of tab bar self.

Using GeometryReader and PreferenceKey are the handy tool for that

import Combine

struct Size: PreferenceKey {

    typealias Value = [CGRect]
    static var defaultValue: [CGRect] = []
    static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
        value.append(contentsOf: nextValue())
    }
}

struct HomeView: View {
    let txt: String
    var body: some View {
        GeometryReader { proxy in
            Text(self.txt).preference(key: Size.self, value: [proxy.frame(in: CoordinateSpace.global)])
        }
    }
}


struct ContentView: View {
    @State var playerFrame = CGRect.zero
    var body: some View {

        TabView {
            HomeView(txt: "Hello").tabItem {
                Image(systemName: "house")
                Text("A")
            }.border(Color.green).tag(1)

            HomeView(txt: "World!").tabItem {
                Image(systemName: "house")
                Text("B")
            }.border(Color.red).tag(2)

            HomeView(txt: "Bla bla").tabItem {
                Image(systemName: "house")
                Text("C")
            }.border(Color.blue).tag(3)
        }
        .onPreferenceChange(Size.self, perform: { (v) in
            self.playerFrame = v.last ?? .zero
            print(self.playerFrame)
        })
            .overlay(
                Color.yellow.opacity(0.2)
            .frame(width: playerFrame.width, height: playerFrame.height)
            .position(x: playerFrame.width / 2, y: playerFrame.height / 2)
        )
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

In the example I reduce the size with .padding() on yellow transparent rectangle, to be sure no part could be hidden (out of screen)

enter image description here

Even the height of tab bar could be calculated, if necessary, but I am not able to imagine for what.

Solvency answered 9/2, 2020 at 11:48 Comment(5)
This is a great approach to solve the inverse part of the equation, which could be very useful. I've used the above answer to "solve" my problem in the short term, but this looks like a great, robust way to handle this for future tasks..Pyrosis
How to read the Size struct in another View?Norrie
Why is v.last is nil in my testing?Hinduism
But how do you get the distance from the bottom of the screen to the top of TabBar?Primp
@Primp There is a good example here: moussahellal.medium.com/…Symbiosis
S
6

Expanding upon the answer from user3441734, you should be able to use this Size to get the difference between TabView and content view sizes. That difference is then the offset where the player needs to be positioned. Here is a rewritten version that should work:

import SwiftUI

struct InnerContentSize: PreferenceKey {
  typealias Value = [CGRect]

  static var defaultValue: [CGRect] = []
  static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
    value.append(contentsOf: nextValue())
  }
}

struct HomeView: View {
  let txt: String
  var body: some View {
    GeometryReader { proxy in
      Text(self.txt)
        .preference(key: InnerContentSize.self, value: [proxy.frame(in: CoordinateSpace.global)])
    }
  }
}

struct PlayerView: View {
  var playerOffset: CGFloat

  var body: some View {
    VStack(alignment: .leading) {
      Spacer()
      HStack {
        Rectangle()
          .fill(Color.blue)
          .frame(width: 55, height: 55)
          .cornerRadius(8.0)
        Text("Name of really cool song")
        Spacer()
        Image(systemName: "play.circle")
          .font(.title)
      }
      .padding(.horizontal)
      Spacer()
    }
    .background(Color.pink.opacity(0.2))
    .frame(height: 70)
    .offset(y: -playerOffset)
  }
}

struct ContentView: View {
  @State private var playerOffset: CGFloat = 0

  var body: some View {
    GeometryReader { geometry in
      TabView {
        HomeView(txt: "Foo")
          .tag(0)
          .tabItem {
            Image(systemName: "sun.min")
            Text("Sun")
          }

        HomeView(txt: "Bar")
          .tag(1)
          .tabItem {
            Image(systemName: "moon")
            Text("Moon")
          }

        HomeView(txt: "Baz")
          .tag(2)
          .tabItem {
            Image(systemName: "sparkles")
            Text("Stars")
          }
      }
      .ignoresSafeArea()
      .onPreferenceChange(InnerContentSize.self, perform: { value in
        self.playerOffset = geometry.size.height - (value.last?.height ?? 0)
      })
      .overlay(PlayerView(playerOffset: playerOffset), alignment: .bottom)
    }
  }
}

struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}

Mini Player

Sibilant answered 14/2, 2021 at 3:12 Comment(0)
P
3

hope I'm not too late to help. The following code is my take on the problem. It works with all devices and also updates the height with device rotation for portrait and landscape.

struct TabBarHeighOffsetViewModifier: ViewModifier {
    let action: (CGFloat) -> Void
//MARK: this screenSafeArea helps determine the correct tab bar height depending on device version
    private let screenSafeArea = (UIApplication.shared.windows.first { $0.isKeyWindow }?.safeAreaInsets.bottom ?? 34)

func body(content: Content) -> some View {
    GeometryReader { proxy in
        content
            .onAppear {
                    let offset = proxy.safeAreaInsets.bottom - screenSafeArea
                    action(offset)
            }
            .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
                    let offset = proxy.safeAreaInsets.bottom - screenSafeArea
                    action(offset)
            }
        }
    }
}

extension View {
    func tabBarHeightOffset(perform action: @escaping (CGFloat) -> Void) -> some View {
        modifier(TabBarHeighOffsetViewModifier(action: action))
    }
}

struct MainTabView: View {

    var body: some View {
        TabView {
            Text("Add the extension on subviews of tabview")
                .tabBarHeightOffset { offset in
                    print("the offset of tabview is -\(offset)")
                }
        }
    }
}

The offset can be applied to views to hover over the tabbar.

Petronilapetronilla answered 13/6, 2021 at 11:47 Comment(1)
But screenSafeAreawill never be updated because it is a let?Vineyard
P
0

@Asperi 's answer works but unfortunately does not support device orientation changes + there is an issue with the offset.

Here is what needs to change :

Edit the @State private var offset:CGFloat = 0 value by this :

offset = tabBar.bounds.height-tabBar.safeAreaInsets.bottom

And then add this to the ViewController :

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransition(to: size, with: coordinator)
    DispatchQueue.main.async {
        if let tabBar = self.tabBarController {
            self.callback(tabBar.tabBar)
        }
    }
}
Primp answered 29/7, 2022 at 22:48 Comment(0)
S
0

You can use .safeAreaInset modifier and .offset

struct ContentView: View {
    var body: some View {
        GeometryReader { geometry in
            AppTabView()
                .safeAreaInset(edge: .bottom) {
                    PlayerView(height: geometry.size.height * 0.1,
                               offset: 48)
                }
        }
    }
}

Player view image

Sadiras answered 13/5 at 7:52 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.