以SwiftUI为基础的程序如何在代码中检测出Tab Bar或TabView的高度?

10

我有一个SwiftUI应用程序,将会有一个浮动的播客播放器,类似于苹果音乐播放器,它位于选项卡栏上方并在所有选项卡和视图中保持存在,而播放器正在运行。我还没有找到一个好的方法来定位播放器,使其与选项卡栏紧密相连,因为选项卡栏的高度根据设备不同而变化。我发现的主要问题是如何在应用程序的根视图中的覆盖层或ZStack中定位播放器,而不是在TabView本身内部。由于我们无法自定义TabView布局的视图层次结构,因此没有办法在TabBar本身和其上面的内容之间插入一个视图。我的基本代码结构:

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)
    }
  })

主要问题在于PlayerContainerView的位置是硬编码的,它具有58像素的填充,以便清除TabView。如果我能检测到TabView的实际框架高度,我就可以全局调整这个值以适应给定设备,这样就可以解决了。有谁知道如何可靠地做到这一点吗?或者您是否有任何想法,如何将PlayerContainerView放置在TabView本身内部,以便在切换显示时,简单地出现在Home()视图和选项卡栏之间?感谢您的任何反馈。

你尝试使用GeometryReader了吗? - Chris
是的,但我无法找到如何针对TabView进行操作...至少我无法找到实际对应于该特定设备选项卡栏绘制高度的相关高度值。 - eResourcesInc
@eResourcesInc 我试过了,你可以在我的回答中看到它是如何工作的... - user3441734
5个回答

18

由于UIKit官方已经允许并记录了桥接,因此在需要时可以从那里直接读取所需信息。

以下是一种直接从 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)
        }
    }
}

1
这个方法是可行的,但是它给我的值是 iPhone 11 Max Pro 的 83。实际距离(至少在填充方面)是 60 像素。我认为你的解决方案给了我到边缘的距离,再加上不安全区域之类的东西。问题是,我必须根据 SwiftUI 的边界在应用程序中排列物品,这意味着除非我知道安全区域和非安全区域之间的设备特定差异,否则我不能使用这个精确的计算。在这种情况下,它是 23 像素,但在其他情况下它会有所不同。有什么想法如何处理这个问题吗? - eResourcesInc
由于某种原因,在iPadOS上使用“when”将使选项卡栏消失(变为透明)。 - lcpr_phoenix
除了每个选项卡之外,是否有其他方法可以获取“tabBar.bounds.height”? - Joe Huang
@eResourcesInc,tabBar.bounds.height的值是tabbar [+bottom safeArea],因此通过减去底部安全区域,您可以获得tapbar的确切高度,在非缩放模式下大多数设备为49。请注意,tapbar分隔符在其边界外渲染一个像素,因此要将视图定位在tapbar正上方,您必须将1/3点添加到填充中。 - Shengchalover
@Shengchalover 请看下面我的回答。简而言之,我们可以通过 tabBar.bounds.height - tabBar.safeAreaInsets.bottom 来获取偏移量。 - LetsGoBrandon
显示剩余3条评论

11

看起来您需要知道播放器的最大尺寸(标签栏上方的空间大小),而不是标签栏本身的高度。

使用GeometryReader和PreferenceKey是实现此功能的便捷工具。

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()
    }
}

在这个例子中,我使用.padding()在黄色透明矩形上减小了大小,以确保没有部分被隐藏(超出屏幕)。

进入图片描述

如果需要的话,甚至可以计算选项卡栏的高度,但我无法想象这样做有什么意义。

这是一个很好的方法来解决方程的反向部分,非常有用。我已经使用上面的答案在短期内“解决”了我的问题,但这看起来是一个很好、强大的方式来处理未来的任务。 - eResourcesInc
如何在另一个 View 中读取 Size 结构体? - user6539552
为什么我的测试中v.last是nil? - Joe Huang
1
但是你如何获取从屏幕底部到TabBar顶部的距离? - LetsGoBrandon

5

进一步扩展用户3441734的答案,您应该能够使用此Size来获取TabView和内容视图大小之间的差异。然后,该差异就是需要定位播放器的偏移量。以下是应该可行的重写版本:

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


3

希望我来得不算太晚。以下代码是我对这个问题的解决方案。它适用于所有设备,并且可以针对纵向和横向旋转更新高度。

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)")
                }
        }
    }
}

可以将偏移量应用于视图以悬浮在选项卡栏上方。


但是screenSafeArea永远不会被更新,因为它是一个let变量? - JeanDujardin

0

@Asperi的答案是有效的,但不幸的是它不支持设备方向的更改+存在偏移问题。

这里需要更改的内容:

通过以下方式编辑@State private var offset:CGFloat = 0值:

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

然后将此添加到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)
        }
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接