Keep the split view mini player on pushed pages

The detail column absorbs its NavigationStack, so the mini player inset
on the stack only reached the root page and disappeared on push. Float
it over the split view, past the sidebar, and have every page reserve
its height as bottom safe area.
This commit is contained in:
Arkadiusz Fal
2026-10-13 23:58:49 +02:00
parent f289905dd8
commit 97288b4497
2 changed files with 87 additions and 18 deletions

View File

@@ -189,6 +189,7 @@ struct NavigationDestinationHandlerModifier: ViewModifier {
func body(content: Content) -> some View {
content
.opaqueWindowBackground()
.miniPlayerBottomClearance()
.navigationDestination(for: NavigationDestination.self) { destination in
Group {
if let transitionID = destination.transitionID {
@@ -199,10 +200,39 @@ struct NavigationDestinationHandlerModifier: ViewModifier {
}
}
.opaqueWindowBackground()
.miniPlayerBottomClearance()
}
}
}
extension EnvironmentValues {
/// Height of the split view's floating mini player, which every page in
/// the detail column reserves as bottom safe area. Zero elsewhere.
@Entry var miniPlayerBottomClearance: CGFloat = 0
}
/// Reserves the floating mini player's height at the bottom of a page.
private struct MiniPlayerBottomClearanceModifier: ViewModifier {
@Environment(\.miniPlayerBottomClearance) private var clearance
func body(content: Content) -> some View {
#if os(iOS)
content
.safeAreaInset(edge: .bottom, spacing: 0) {
Color.clear.frame(height: clearance)
}
#else
content
#endif
}
}
private extension View {
func miniPlayerBottomClearance() -> some View {
modifier(MiniPlayerBottomClearanceModifier())
}
}
extension View {
/// Adds navigation destination handlers for all app navigation destinations.
/// Use this on views within a NavigationStack that contain NavigationLink(value:).

View File

@@ -82,27 +82,58 @@ struct UnifiedTabView: View {
}
#endif
#if os(iOS)
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
/// Global trailing edge of the sidebar column and leading edge of the split
/// view, so the mini player can sit over the detail column.
@State private var sidebarTrailingEdge: CGFloat = 0
@State private var splitViewLeadingEdge: CGFloat = 0
@State private var miniPlayerClearance: CGFloat = 0
#endif
var body: some View {
splitView
.zoomTransitionNamespace(zoomTransition)
#if os(iOS)
.zoomTransitionsEnabled(zoomTransitionsEnabled)
.onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).minX } action: {
splitViewLeadingEdge = $0
}
// The detail column absorbs its NavigationStack, so modifiers on the
// stack only reach its root page and vanish on push. The mini player
// floats over the whole split view instead, and every page reserves
// room for it through the environment.
.overlay(alignment: .bottom) { miniPlayerOverlay }
.environment(\.miniPlayerBottomClearance, miniPlayerClearance)
#endif
.onAppear {
configureSidebarManager()
router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home)
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
}
.onChange(of: router.selection) { _, _ in
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
}
}
@ViewBuilder
private var splitView: some View {
#if os(iOS)
NavigationSplitView(columnVisibility: $columnVisibility) {
sidebar
.onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).maxX } action: {
sidebarTrailingEdge = $0
}
} detail: {
detailForSelection
}
#else
NavigationSplitView {
sidebar
} detail: {
detailForSelection
#if os(iOS)
.safeAreaInset(edge: .bottom, spacing: 0) { miniPlayerInset }
#endif
}
.zoomTransitionNamespace(zoomTransition)
#if os(iOS)
.zoomTransitionsEnabled(zoomTransitionsEnabled)
#endif
.onAppear {
configureSidebarManager()
router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home)
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
}
.onChange(of: router.selection) { _, _ in
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
}
}
// MARK: - Visible Main Items
@@ -276,11 +307,15 @@ struct UnifiedTabView: View {
#if os(iOS)
/// The split view has no tab bar to host the mini player as a bottom
/// accessory, so it takes the detail column's bottom safe area instead:
/// floating content such as the subscriptions channel strip and the last
/// rows of every list then clear it, as they clear the tab accessory.
private var miniPlayerInset: some View {
/// accessory, so it floats at the bottom of the detail column and pages
/// reserve its height as bottom safe area: floating content such as the
/// subscriptions channel strip and the last rows of every list then clear
/// it, as they clear the tab accessory.
private var miniPlayerOverlay: some View {
let hasActiveVideo = appEnvironment?.playerService.state.currentVideo != nil
let sidebarWidth = columnVisibility == .detailOnly
? 0
: max(0, sidebarTrailingEdge - splitViewLeadingEdge)
// Stays mounted under the expanded player: the dismiss gesture morphs
// the video into its preview, so it must already be in place
@@ -293,6 +328,10 @@ struct UnifiedTabView: View {
.transition(.move(edge: .bottom))
}
}
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: {
miniPlayerClearance = $0
}
.padding(.leading, sidebarWidth)
.animation(.spring(response: 0.3), value: hasActiveVideo)
}
#endif