Skip to content

Commit f224c10

Browse files
authored
Fix the tab rail flickering on narrow iPad windows (#6107)
* Fix the tab rail flickering on narrow iPad windows The rail is laid out beside the split view, so hiding it when the split collapsed handed that width back to the split, which then had room for its sidebar again, which showed the rail, which collapsed the split. Any window whose width sat within a rail of the split's collapse threshold flickered forever, an iPad mini in fullscreen among them. The split reports the same column visibility whether it collapsed on its own or because the sidebar was toggled, and at the same width, so the two can only be told apart by when they arrive: the rebound lands in the next layout pass. A collapse that soon after showing the rail is therefore taken as proof that the rail and the sidebar don't fit at this width, and the rail is left hidden until the window is resized. Later collapses are treated as deliberate and still hide the rail as before. The container width, the width the rail and the sidebar were shown not to fit at, and the moment the rail was last shown are one piece of state with invariants between them rather than three independent values, so keep them and the decision they feed together in TabRailVisibility.
1 parent b4395d9 commit f224c10

1 file changed

Lines changed: 51 additions & 5 deletions

File tree

ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,48 @@ extension EnvironmentValues {
307307
@Entry var tabViewHorizontalSizeClass: UserInterfaceSizeClass?
308308
}
309309

310+
/// Tracks whether the tab rail should be shown alongside the split view's sidebar.
311+
///
312+
/// The rail is laid out beside the split view, so hiding it hands that width back to the split, which may
313+
/// then have room for its sidebar again, which would show the rail, ad infinitum. The split reports the
314+
/// same column visibility whether it collapsed on its own or because the sidebar was toggled, and at the
315+
/// same width, so the two are told apart by when they arrive: the rebound lands in the next layout pass.
316+
private struct TabRailVisibility {
317+
/// The longest a collapse can follow the rail being shown and still be considered caused by it.
318+
private static let reboundDuration: TimeInterval = 0.5
319+
320+
private(set) var isVisible = true
321+
322+
private var containerWidth: CGFloat = 0
323+
/// The container width at which the rail and the sidebar have been shown not to fit together.
324+
private var blockedWidth: CGFloat?
325+
/// When the rail was last shown, used to spot the split view collapsing as a direct result of it.
326+
private var shownDate = Date.distantPast
327+
328+
mutating func containerWidthChanged(to width: CGFloat) {
329+
guard width != containerWidth else { return }
330+
331+
containerWidth = width
332+
blockedWidth = nil // The rail and the sidebar may well fit at the new width.
333+
}
334+
335+
mutating func splitVisibilityChanged(to visibility: NavigationSplitViewVisibility) {
336+
if visibility == .detailOnly {
337+
// A collapse this soon after showing the rail was caused by the rail's own width, so showing
338+
// it again would only collapse the split view again, flickering forever. Note that the two
339+
// don't fit at this width and leave the rail hidden until that changes.
340+
if shownDate.timeIntervalSinceNow > -Self.reboundDuration {
341+
blockedWidth = containerWidth
342+
}
343+
344+
isVisible = false
345+
} else if !isVisible, blockedWidth != containerWidth {
346+
isVisible = true
347+
shownDate = .now
348+
}
349+
}
350+
}
351+
310352
private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
311353
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
312354

@@ -315,7 +357,7 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
315357
@State private var standardAppearance = UITabBarAppearance()
316358
@State private var window: UIWindow?
317359
@State private var isFullScreen = true
318-
@State private var isRailVisible = true
360+
@State private var railVisibility = TabRailVisibility()
319361
@State private var railBackgroundColor: Color = .compound.bgCanvasDefault
320362

321363
var body: some View {
@@ -361,7 +403,7 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
361403

362404
var tabRailLayout: some View {
363405
HStack(spacing: 0) {
364-
if isRailVisible {
406+
if railVisibility.isVisible {
365407
TabRailView(navigationTabCoordinator: navigationTabCoordinator, isFullScreen: isFullScreen)
366408
.background(railBackgroundColor.ignoresSafeArea())
367409
.animation(.easeInOut(duration: 0.4).disabledDuringTests(), value: railBackgroundColor)
@@ -385,8 +427,10 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
385427
}
386428
}
387429
.onGeometryChange(for: CGSize.self) { geometry in
388-
geometry.size // We don't need the size, but it's a signal to re-compute.
389-
} action: { _ in
430+
geometry.size
431+
} action: { size in
432+
railVisibility.containerWidthChanged(to: size.width)
433+
390434
guard let newValue = window?.isFullScreen else { return }
391435

392436
if newValue != isFullScreen {
@@ -399,7 +443,9 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
399443
// feedback loop that locks up the app while resizing.
400444
//
401445
// Use a stored value (instead of a computed value) that is updated on the next run loop to break the loop.
402-
DispatchQueue.main.async { isRailVisible = selectedTabSplitColumnVisibility != .detailOnly }
446+
DispatchQueue.main.async {
447+
railVisibility.splitVisibilityChanged(to: selectedTabSplitColumnVisibility)
448+
}
403449
}
404450
}
405451

0 commit comments

Comments
 (0)