From 977b76b2d5188a60f12609952c7c99e5d4d980f9 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sat, 24 Oct 2026 12:06:04 -0500 Subject: [PATCH] Action buttons in the vertical status bar column on iPhone Duo When the status bar is vertical (inner display in landscape, and the cover display), the bottom tab bar overlapped the table and clipped the last button. Hide it there and show the four actions in a glass capsule in the status bar's column, below the clock, where the system places vertical toolbar items. The vertical bar is detected from the safe area (no top inset, one side inset) rather than the iOS 27.1 verticalBarEdge APIs, so this still builds with Xcode 27.0. --- Loop/Views/StatusTableView.swift | 87 ++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/Loop/Views/StatusTableView.swift b/Loop/Views/StatusTableView.swift index ab7a6cfbe8..6f59b1be3a 100644 --- a/Loop/Views/StatusTableView.swift +++ b/Loop/Views/StatusTableView.swift @@ -301,12 +301,30 @@ enum ActionTabBarMetrics { static let iconHeight: CGFloat = 34 - static var bottomSafeAreaInset: CGFloat { + private static var keyWindow: UIWindow? { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .flatMap { $0.windows } - .first { $0.isKeyWindow }? - .safeAreaInsets.bottom ?? 0 + .first { $0.isKeyWindow } + } + + static var bottomSafeAreaInset: CGFloat { + keyWindow?.safeAreaInsets.bottom ?? 0 + } + + static var hasVerticalStatusBar: Bool { + guard let insets = keyWindow?.safeAreaInsets else { return false } + return verticalStatusBarEdge(for: EdgeInsets(top: insets.top, leading: insets.left, bottom: insets.bottom, trailing: insets.right)) != nil + } + + // A vertical status bar (iPhone Duo) leaves no top inset and insets one side only. + static func verticalStatusBarEdge(for insets: EdgeInsets) -> HorizontalEdge? { + guard insets.top == 0 else { return nil } + switch (insets.leading > 0, insets.trailing > 0) { + case (true, false): return .leading + case (false, true): return .trailing + default: return nil + } } static var isCompactHeight: Bool { @@ -318,7 +336,7 @@ enum ActionTabBarMetrics { } static var tableContentInset: CGFloat { - guard !isCompactHeight else { return 0 } + guard !isCompactHeight, !hasVerticalStatusBar else { return 0 } if #available(iOS 26.0, *), bottomSafeAreaInset == 0 { return barHeight + 40 } @@ -371,11 +389,62 @@ struct ActionTabView: View { } var body: some View { - let isVisible = verticalSizeClass != .compact - content - .safeAreaInset(edge: .bottom, spacing: 0) { - ActionTabBar(items: tabs, isHidden: !isVisible) - .modifier(LegacyTabBarBackground(isVisible: isVisible)) + GeometryReader { proxy in + let insets = proxy.safeAreaInsets + let verticalEdge = verticalSizeClass == .compact ? nil : ActionTabBarMetrics.verticalStatusBarEdge(for: insets) + let isVisible = verticalSizeClass != .compact && verticalEdge == nil + content + .safeAreaInset(edge: .bottom, spacing: 0) { + ActionTabBar(items: tabs, isHidden: !isVisible) + .modifier(LegacyTabBarBackground(isVisible: isVisible)) + } + .overlay(alignment: verticalEdge == .leading ? .bottomLeading : .bottomTrailing) { + // In the status bar's column, below it, as the system places vertical toolbar buttons + if let verticalEdge { + VerticalActionBar(items: tabs) + .frame(width: verticalEdge == .leading ? insets.leading : insets.trailing) + .padding(.bottom, max(insets.bottom, 12)) + .ignoresSafeArea(edges: verticalEdge == .leading ? [.leading, .bottom] : [.trailing, .bottom]) + } + } + } + } +} + +struct VerticalActionBar: View { + + let items: [ActionTab] + + var body: some View { + VStack(spacing: 4) { + ForEach(items) { item in + Button(action: item.action) { + Image(item.icon) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(height: ActionTabBarMetrics.iconHeight) + .foregroundStyle(Color(item.tintColor)) + .frame(width: 52, height: 52) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(item.title) } + } + .padding(.vertical, 6) + .modifier(VerticalActionBarBackground()) + } +} + +private struct VerticalActionBarBackground: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.background { + Color.clear.glassEffect(.regular, in: .capsule) + } + } else { + content.background(Color(UIColor.secondarySystemBackground), in: Capsule()) + } } }