mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +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 Combine
|
||||||
|
import Compound
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Class responsible for displaying 2 coordinators side by side and collapsing them
|
/// Class responsible for displaying 2 coordinators side by side and collapsing them
|
||||||
@@ -154,6 +155,9 @@ import SwiftUI
|
|||||||
compactLayoutStackModules.compactMap(\.coordinator)
|
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
|
/// Default NavigationSplitCoordinator initialiser
|
||||||
/// - Parameter placeholderCoordinator: coordinator to use if no siderbar or detail is set
|
/// - Parameter placeholderCoordinator: coordinator to use if no siderbar or detail is set
|
||||||
init(placeholderCoordinator: CoordinatorProtocol) {
|
init(placeholderCoordinator: CoordinatorProtocol) {
|
||||||
@@ -331,8 +335,6 @@ extension EnvironmentValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct NavigationSplitCoordinatorView: View {
|
private struct NavigationSplitCoordinatorView: View {
|
||||||
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
|
||||||
|
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@@ -358,13 +360,13 @@ private struct NavigationSplitCoordinatorView: View {
|
|||||||
module.coordinator?.toPresentable()
|
module.coordinator?.toPresentable()
|
||||||
.id(module.id)
|
.id(module.id)
|
||||||
}
|
}
|
||||||
.onChange(of: columnVisibility) { oldValue, _ in
|
.onChange(of: navigationSplitCoordinator.columnVisibility) { oldValue, _ in
|
||||||
// Preserve the current column visibility when backgrounding the app
|
// Preserve the current column visibility when backgrounding the app
|
||||||
if scenePhase == .background {
|
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
|
/// 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
|
/// The NavigationSplitView that will be used in non-compact layouts
|
||||||
var navigationSplitView: some View {
|
var navigationSplitView: some View {
|
||||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
NavigationSplitView(columnVisibility: $navigationSplitCoordinator.columnVisibility) {
|
||||||
if let sidebarModule = navigationSplitCoordinator.sidebarModule {
|
if let sidebarModule = navigationSplitCoordinator.sidebarModule {
|
||||||
sidebarModule.coordinator?.toPresentable()
|
sidebarModule.coordinator?.toPresentable()
|
||||||
.environment(\.isInSidebar, true)
|
.environment(\.isInSidebar, true)
|
||||||
|
|||||||
@@ -303,8 +303,84 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
|
|||||||
@Bindable var navigationTabCoordinator: NavigationTabCoordinator<Tag>
|
@Bindable var navigationTabCoordinator: NavigationTabCoordinator<Tag>
|
||||||
|
|
||||||
@State private var standardAppearance = UITabBarAppearance()
|
@State private var standardAppearance = UITabBarAppearance()
|
||||||
|
@State private var window: UIWindow?
|
||||||
|
@State private var isFullScreen = true
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
TabView(selection: $navigationTabCoordinator.selectedTab) {
|
||||||
ForEach(navigationTabCoordinator.tabModules) { module in
|
ForEach(navigationTabCoordinator.tabModules) { module in
|
||||||
Tab(value: module.details.tag, role: module.details.isSearch ? .search : nil) {
|
Tab(value: module.details.tag, role: module.details.isSearch ? .search : nil) {
|
||||||
@@ -323,26 +399,6 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
|
|||||||
}
|
}
|
||||||
.backportTabBarMinimizeBehaviorOnScrollDown()
|
.backportTabBarMinimizeBehaviorOnScrollDown()
|
||||||
.introspect(.tabView, on: .supportedVersions, customize: configureAppearance)
|
.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) {
|
private func configureAppearance(_ tabBarController: UITabBarController) {
|
||||||
@@ -353,3 +409,72 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
|
|||||||
tabBarController.tabBar.standardAppearance = standardAppearance
|
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