mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Add a custom TabRail on iPad instead of the top tab bar.
Warning: There's currently an observation tracking feedback loop 😕
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
/// Class responsible for displaying 2 coordinators side by side and collapsing them
|
||||
@@ -154,6 +155,9 @@ import SwiftUI
|
||||
compactLayoutStackModules.compactMap(\.coordinator)
|
||||
}
|
||||
|
||||
/// Tracks the current column visibility of the split view. Only meaningful in regular (non-compact) layouts.
|
||||
var columnVisibility = NavigationSplitViewVisibility.all
|
||||
|
||||
/// Default NavigationSplitCoordinator initialiser
|
||||
/// - Parameter placeholderCoordinator: coordinator to use if no siderbar or detail is set
|
||||
init(placeholderCoordinator: CoordinatorProtocol) {
|
||||
@@ -331,8 +335,6 @@ extension EnvironmentValues {
|
||||
}
|
||||
|
||||
private struct NavigationSplitCoordinatorView: View {
|
||||
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
||||
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -358,13 +360,13 @@ private struct NavigationSplitCoordinatorView: View {
|
||||
module.coordinator?.toPresentable()
|
||||
.id(module.id)
|
||||
}
|
||||
.onChange(of: columnVisibility) { oldValue, _ in
|
||||
.onChange(of: navigationSplitCoordinator.columnVisibility) { oldValue, _ in
|
||||
// Preserve the current column visibility when backgrounding the app
|
||||
if scenePhase == .background {
|
||||
columnVisibility = oldValue
|
||||
navigationSplitCoordinator.columnVisibility = oldValue
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea() // Necessary when embedded in a TabView on iPadOS otherwise there's a gap at the top (as of 18.5).
|
||||
.ignoresSafeArea(edges: Compound.supportsGlass ? [] : .all) // When embedded in a TabView on iPadOS 18 there's a gap at the top.
|
||||
}
|
||||
|
||||
/// The NavigationStack that will be used in compact layouts
|
||||
@@ -382,7 +384,7 @@ private struct NavigationSplitCoordinatorView: View {
|
||||
|
||||
/// The NavigationSplitView that will be used in non-compact layouts
|
||||
var navigationSplitView: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
NavigationSplitView(columnVisibility: $navigationSplitCoordinator.columnVisibility) {
|
||||
if let sidebarModule = navigationSplitCoordinator.sidebarModule {
|
||||
sidebarModule.coordinator?.toPresentable()
|
||||
.environment(\.isInSidebar, true)
|
||||
|
||||
@@ -303,8 +303,84 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
|
||||
@Bindable var navigationTabCoordinator: NavigationTabCoordinator<Tag>
|
||||
|
||||
@State private var standardAppearance = UITabBarAppearance()
|
||||
@State private var window: UIWindow?
|
||||
@State private var isFullScreen = true
|
||||
|
||||
var body: some View {
|
||||
tabView
|
||||
.sheet(item: $navigationTabCoordinator.sheetModule) { module in
|
||||
module.coordinator?.toPresentable()
|
||||
.id(module.id)
|
||||
}
|
||||
.fullScreenCover(item: $navigationTabCoordinator.fullScreenCoverModule) { module in
|
||||
module.coordinator?.toPresentable()
|
||||
.id(module.id)
|
||||
}
|
||||
.accessibilityHidden(navigationTabCoordinator.overlayModule?.coordinator != nil && navigationTabCoordinator.overlayPresentationMode == .fullScreen)
|
||||
.overlay {
|
||||
Group {
|
||||
if let coordinator = navigationTabCoordinator.overlayModule?.coordinator {
|
||||
coordinator.toPresentable()
|
||||
.opacity(navigationTabCoordinator.overlayPresentationMode == .minimized ? 0 : 1)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: navigationTabCoordinator.overlayPresentationMode)
|
||||
.animation(.elementDefault, value: navigationTabCoordinator.overlayModule)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var tabView: some View {
|
||||
if horizontalSizeClass == .regular, #available(iOS 26, *) {
|
||||
tabRailLayout
|
||||
} else {
|
||||
tabViewLayout
|
||||
}
|
||||
}
|
||||
|
||||
/// The column visibility of the split coordinator for the currently selected tab, if any.
|
||||
private var selectedTabSplitColumnVisibility: NavigationSplitViewVisibility {
|
||||
let selectedTabDetails = navigationTabCoordinator.tabModules
|
||||
.first { $0.details.tag == navigationTabCoordinator.selectedTab }?.details
|
||||
return selectedTabDetails?.navigationSplitCoordinator?.columnVisibility ?? .all
|
||||
}
|
||||
|
||||
private var isRailVisible: Bool {
|
||||
selectedTabSplitColumnVisibility != .detailOnly
|
||||
}
|
||||
|
||||
var tabRailLayout: some View {
|
||||
HStack(spacing: 0) {
|
||||
if isRailVisible {
|
||||
TabRailView(navigationTabCoordinator: navigationTabCoordinator, isFullScreen: isFullScreen)
|
||||
}
|
||||
|
||||
if let module = navigationTabCoordinator.tabModules.first(where: { $0.details.tag == navigationTabCoordinator.selectedTab }) {
|
||||
module.coordinator?.toPresentable()
|
||||
.id(module.id)
|
||||
}
|
||||
}
|
||||
.introspect(.window, on: .supportedVersions) { window in
|
||||
guard self.window !== window else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.window = window
|
||||
isFullScreen = window.isFullScreen
|
||||
}
|
||||
}
|
||||
.onGeometryChange(for: CGSize.self) { geometry in
|
||||
geometry.size // We don't need the size, but it's a signal to re-compute.
|
||||
} action: { _ in
|
||||
guard let newValue = window?.isFullScreen else { return }
|
||||
|
||||
if newValue != isFullScreen {
|
||||
isFullScreen = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tabViewLayout: some View {
|
||||
TabView(selection: $navigationTabCoordinator.selectedTab) {
|
||||
ForEach(navigationTabCoordinator.tabModules) { module in
|
||||
Tab(value: module.details.tag, role: module.details.isSearch ? .search : nil) {
|
||||
@@ -323,26 +399,6 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
|
||||
}
|
||||
.backportTabBarMinimizeBehaviorOnScrollDown()
|
||||
.introspect(.tabView, on: .supportedVersions, customize: configureAppearance)
|
||||
.sheet(item: $navigationTabCoordinator.sheetModule) { module in
|
||||
module.coordinator?.toPresentable()
|
||||
.id(module.id)
|
||||
}
|
||||
.fullScreenCover(item: $navigationTabCoordinator.fullScreenCoverModule) { module in
|
||||
module.coordinator?.toPresentable()
|
||||
.id(module.id)
|
||||
}
|
||||
.accessibilityHidden(navigationTabCoordinator.overlayModule?.coordinator != nil && navigationTabCoordinator.overlayPresentationMode == .fullScreen)
|
||||
.overlay {
|
||||
Group {
|
||||
if let coordinator = navigationTabCoordinator.overlayModule?.coordinator {
|
||||
coordinator.toPresentable()
|
||||
.opacity(navigationTabCoordinator.overlayPresentationMode == .minimized ? 0 : 1)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.elementDefault, value: navigationTabCoordinator.overlayPresentationMode)
|
||||
.animation(.elementDefault, value: navigationTabCoordinator.overlayModule)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureAppearance(_ tabBarController: UITabBarController) {
|
||||
@@ -353,3 +409,72 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
|
||||
tabBarController.tabBar.standardAppearance = standardAppearance
|
||||
}
|
||||
}
|
||||
|
||||
struct TabRailView<Tag: Hashable>: View {
|
||||
let navigationTabCoordinator: NavigationTabCoordinator<Tag>
|
||||
let isFullScreen: Bool
|
||||
|
||||
var topPadding: CGFloat {
|
||||
// Add additional top padding on iPad when the traffic light buttons are shown.
|
||||
isFullScreen || ProcessInfo.processInfo.isiOSAppOnMac ? 0 : 48
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
ForEach(navigationTabCoordinator.tabModules) { module in
|
||||
let isSelected = module.details.tag == navigationTabCoordinator.selectedTab
|
||||
Button {
|
||||
navigationTabCoordinator.selectedTab = module.details.tag
|
||||
} label: {
|
||||
Label {
|
||||
Text(module.details.title)
|
||||
} icon: {
|
||||
CompoundIcon(isSelected ? module.details.selectedIcon : module.details.icon)
|
||||
}
|
||||
}
|
||||
.buttonStyle(TabButtonStyle(isSelected: isSelected))
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
.badge(10) // TODO: Check if this works.
|
||||
}
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
.padding(.top, topPadding)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityAddTraits(.isTabBar)
|
||||
}
|
||||
|
||||
struct TabButtonStyle: ButtonStyle {
|
||||
let isSelected: Bool
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.labelStyle(.iconOnly)
|
||||
.foregroundStyle(.compound.iconPrimary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(capsuleColor(isPressed: configuration.isPressed), in: .capsule)
|
||||
.contentShape(.capsule)
|
||||
}
|
||||
|
||||
func capsuleColor(isPressed: Bool) -> Color {
|
||||
if isSelected {
|
||||
.compound.bgSubtleSecondary
|
||||
} else if isPressed {
|
||||
.compound.bgSubtleSecondary.opacity(0.5)
|
||||
} else {
|
||||
.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIWindow {
|
||||
var isFullScreen: Bool {
|
||||
frame.size == windowScene?.screen.bounds.size
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user