Программное определение панели вкладок или высоты TabView в SwiftUI

У меня есть приложение SwiftUI, которое будет иметь плавающий проигрыватель подкастов, похожий на проигрыватель Apple Music, который находится прямо над панелью вкладок и сохраняется во всех вкладках и представлениях во время работы проигрывателя. Я не придумал, как правильно расположить проигрыватель так, чтобы он был на одном уровне над панелью вкладок, поскольку высота панели вкладок изменяется в зависимости от устройства. Основная проблема, которую я обнаружил, заключается в том, как я должен позиционировать проигрыватель в оверлее или ZStack в корневом представлении моего приложения, а не в самом TabView. Поскольку мы не можем настроить иерархию представлений макета TabView, нет способа вставить представление между самим Tab Bar и содержимым представления над ним. Моя основная структура кода:

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() и панелью вкладок, когда он переключается на отображение? Любая обратная связь будет оценена.

6 ответов

Решение

Поскольку мост к 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)
        }
    }
}

Кажется, что вам нужно знать максимальный размер плеера (размер пространства над панелью вкладок), а не высоту панели вкладок.

Использование 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() на желтом прозрачном прямоугольнике, чтобы убедиться, что никакая часть не может быть скрыта (за пределами экрана)

При необходимости можно вычислить даже высоту панели вкладок, но я не могу представить, для чего.

Расширяя ответ от user3441734, вы должны иметь возможность использовать этот размер, чтобы получить разницу между размером 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()
  }
}

надеюсь, я не слишком поздно, чтобы помочь. Следующий код - это мой взгляд на проблему. Он работает со всеми устройствами, а также обновляет высоту с поворотом устройства для портретной и альбомной ориентации.

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

Смещение можно применять к представлениям, чтобы навести курсор на панель вкладок.

Ответ @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)
        }
    }
}

Это еще одно использование ответа @Asperi.

Мне нужно было изменить цвет фона панели вкладок в зависимости от выбора пользователя с помощью переключателя. (черный/прозрачный)

В представлении вкладок.

У нас есть доступ к сохраненному bool для пользовательских значений по умолчанию. (они выбирают, используя кнопку переключения в другом месте, которая устанавливает логическое значение)

       @AppStorage("radioViewImageBGSafeEdges") var showBGUnderTabBar: Bool  = true

У нас также есть переменная @State для a. UITabBar

       @State private var tabBar_  = UITabBar()

Это будет загружено с панелью вкладок, которую мы получаем из кода @Asperi.

В моем случае это только одно представление, которое требует изменения панели вкладок. Все остальные остаются прежними.

Поэтому я добавляю модификатор только под эту вкладку.

           RadioView().tag(0).tabItem { Label("Radio", systemImage: "dot.radiowaves.left.and.right") }
                .background(Color.black.opacity(0.5))
.background(TabBarAccessor { tabBar in
                                
                                    //==load the tabBar into our star var for use elsewhere
                                    
                                    self.tabBar_ = tabBar
                                    
                                    //== change colour
                                  tabBar.backgroundColor =      showBGUnderTabBar ?   UIColor.black :  UIColor.clear
                                               })

--

Посмотрите, что я добавил эту строку внутри модификатора.

       self.tabBar_ = tabBar

Теперь у нас есть доступ к панели вкладок, и мы можем использовать ее в модификаторе .onChange(of:) в той же вкладке.

           .onChange(of: showBGUnderTabBar) { newValue in
        
                        tabBar_.backgroundColor =      newValue ?   UIColor.black :  UIColor.clear
    }

Теперь, когда переменная состояния изменится, цвет панели вкладок сразу же изменится.

Другие вопросы по тегам