Repository navigation
iOS: native soft scroll edge effect under the workspace list chrome #8575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
98dd7c1
Add failing test: workspace table must drive nav/tab bar scroll edge …
bb3b122
iOS: render the native soft scroll edge effect under the workspace li…
3300379
iOS: extend the workspace table under the vertical bars so the soft e…
dfc4357
Re-resolve scroll edge bar registration on every layout pass
788334d
Reclaim scroll edge registration after a transient replacement departs
237df1d
Claim scroll edge registration only when vacant or stale
107b6dd
Describe the late-tab-attachment test by the lifecycle it actually ex…
08adcd4
Gate the bar underlap to iOS 26
140bffa
Nudge the waiting table when a departing owner clears a bar edge
545c3dd
Apply review policy: instance helpers and a dedicated underlap file
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
24 changes: 24 additions & 0 deletions
24
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListBarUnderlap.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import SwiftUI | ||
| #if os(iOS) | ||
| /// Extends the workspace table under the vertical bars on iOS 26, where the | ||
| /// scroll edge effect and `WorkspaceListScrollEdgeCoordinator`'s bar | ||
| /// registration render the App Store-style soft blur over the underlap. | ||
| /// | ||
| /// SwiftUI fits a `UIViewRepresentable` inside the safe area, so without this | ||
| /// the table's frame starts below the search drawer and ends above the tab | ||
| /// bar: rows hard-clip at the chrome and no scroll edge effect can render. | ||
| /// The real UIKit bars still contribute safe area, so the table's automatic | ||
| /// content-inset adjustment keeps rows and indicators clear of the chrome, | ||
| /// and the keyboard region stays respected. Earlier releases keep the fitted | ||
| /// frame: the coordinator's registration is iOS 26-gated, and underlapping | ||
| /// without it would scroll full-opacity rows beneath legacy bars. | ||
| struct WorkspaceListBarUnderlap: ViewModifier { | ||
| func body(content: Content) -> some View { | ||
| if #available(iOS 26.0, *) { | ||
| content.ignoresSafeArea(.container, edges: .vertical) | ||
| } else { | ||
| content | ||
| } | ||
| } | ||
| } | ||
| #endif |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
136 changes: 136 additions & 0 deletions
136
.../iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListScrollEdgeCoordinator.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| #if os(iOS) | ||
| import UIKit | ||
|
|
||
| /// Registers the workspace table as the content scroll view of its enclosing | ||
| /// navigation and tab bar controllers so UIKit renders the scroll edge effect | ||
| /// under the top chrome (navigation bar + search drawer) and behind the tab | ||
| /// bar, App Store-style. | ||
| /// | ||
| /// SwiftUI only drives bar scroll edge effects for its own scroll views. The | ||
| /// workspace list is a `UIViewRepresentable` `UITableView`, invisible to that | ||
| /// machinery, so without this registration the table's `.soft` top edge style | ||
| /// never renders and rows hard-clip at the search bar's bottom edge. | ||
| @MainActor | ||
| final class WorkspaceListScrollEdgeCoordinator { | ||
| private weak var registeredScrollView: UIScrollView? | ||
| private weak var navigationContentController: UIViewController? | ||
| private weak var tabContentController: UIViewController? | ||
|
|
||
| /// Re-resolves the bar-owning controllers for `scrollView` and claims any | ||
| /// edge whose registration is vacant. Called on every layout pass: the | ||
| /// controller chain can assemble incrementally (navigation controller | ||
| /// before tab controller) or reparent without the window changing, so a | ||
| /// one-shot registration would strand a partially registered edge. | ||
| /// | ||
| /// Ownership arbitration: an edge is claimed only when its current | ||
| /// registration is nil or held by a detached scroll view. A different | ||
| /// LIVE table keeps its registration, so coexisting tables (SwiftUI | ||
| /// transition overlap) never steal from each other; when the owner | ||
| /// departs it clears the edge and the survivor reclaims on its next | ||
| /// layout pass. When nothing changed this is a no-op costing a short | ||
| /// parent walk and two getter comparisons. | ||
| func registerIfNeeded(for scrollView: UIScrollView) { | ||
| guard #available(iOS 26.0, *) else { return } | ||
| guard scrollView.window != nil else { return } | ||
| let navigationContent = contentController( | ||
| hosting: scrollView, inParentOfKind: UINavigationController.self | ||
| ) | ||
| let tabContent = contentController( | ||
| hosting: scrollView, inParentOfKind: UITabBarController.self | ||
| ) | ||
| guard navigationContent != nil || tabContent != nil else { return } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if navigationContent !== navigationContentController | ||
| || scrollView !== registeredScrollView { | ||
| clearEdgeIfOwned(on: navigationContentController, edge: .top) | ||
| } | ||
| if tabContent !== tabContentController || scrollView !== registeredScrollView { | ||
| clearEdgeIfOwned(on: tabContentController, edge: .bottom) | ||
| } | ||
| registeredScrollView = scrollView | ||
| navigationContentController = navigationContent | ||
| tabContentController = tabContent | ||
|
|
||
| if let navigationContent, | ||
| canClaim(current: navigationContent.contentScrollView(for: .top), | ||
| claimant: scrollView) { | ||
| navigationContent.setContentScrollView(scrollView, for: .top) | ||
| } | ||
| if let tabContent, | ||
| canClaim(current: tabContent.contentScrollView(for: .bottom), | ||
| claimant: scrollView) { | ||
| tabContent.setContentScrollView(scrollView, for: .bottom) | ||
| } | ||
| } | ||
|
|
||
| /// Clears this coordinator's registrations. A registration held by | ||
| /// another table (same controller, different scroll view) is left intact. | ||
| func unregister() { | ||
| guard #available(iOS 26.0, *) else { return } | ||
| clearEdgeIfOwned(on: navigationContentController, edge: .top) | ||
| clearEdgeIfOwned(on: tabContentController, edge: .bottom) | ||
| navigationContentController = nil | ||
| tabContentController = nil | ||
| registeredScrollView = nil | ||
| } | ||
|
|
||
| private func clearEdgeIfOwned(on controller: UIViewController?, edge: NSDirectionalRectEdge) { | ||
| guard let controller, | ||
| let scrollView = registeredScrollView, | ||
| controller.contentScrollView(for: edge) === scrollView else { return } | ||
| controller.setContentScrollView(nil, for: edge) | ||
| // Deterministic handoff: a waiting table stood down while this one | ||
| // owned the edge, and UIKit does not guarantee it another layout pass | ||
| // after this teardown. Nudge it so its next pass claims the vacancy; | ||
| // the woken table still runs its own claim arbitration. | ||
| if let waiting = firstWorkspaceTable( | ||
| under: controller.viewIfLoaded, excluding: scrollView | ||
| ) { | ||
| waiting.setNeedsLayout() | ||
| } | ||
| } | ||
|
|
||
| private func firstWorkspaceTable( | ||
| under view: UIView?, excluding departing: UIScrollView | ||
| ) -> WorkspaceListUITableView? { | ||
| guard let view else { return nil } | ||
| if let table = view as? WorkspaceListUITableView, table !== departing { | ||
| return table | ||
| } | ||
| for subview in view.subviews { | ||
| if let found = firstWorkspaceTable(under: subview, excluding: departing) { | ||
| return found | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| /// Vacant (nil) and stale (holder departed its window without clearing, | ||
| /// e.g. deallocated mid-transition) registrations are claimable. An edge | ||
| /// already held by the claimant needs no re-set, and a different live | ||
| /// scroll view keeps ownership. | ||
| private func canClaim(current: UIScrollView?, claimant: UIScrollView) -> Bool { | ||
| guard let current else { return true } | ||
| if current === claimant { return false } | ||
| return current.window == nil | ||
| } | ||
|
|
||
| /// The last view controller on `view`'s parent chain before the first | ||
| /// container of `Kind`: the content controller whose bars that container | ||
| /// derives from `setContentScrollView(_:for:)` registrations. | ||
| private func contentController<Kind: UIViewController>( | ||
| hosting view: UIView, inParentOfKind kind: Kind.Type | ||
| ) -> UIViewController? { | ||
| var responder: UIResponder? = view.next | ||
| while let current = responder, !(current is UIViewController) { | ||
| responder = current.next | ||
| } | ||
| guard var content = responder as? UIViewController else { return nil } | ||
| while let parent = content.parent { | ||
| if parent is Kind { return content } | ||
| content = parent | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
| #endif | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
132 changes: 132 additions & 0 deletions
132
...S/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListScrollEdgeEffectTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| #if os(iOS) | ||
| import Testing | ||
| import UIKit | ||
| @testable import CmuxMobileShellUI | ||
|
|
||
| /// The workspace table is a `UIViewRepresentable`, so SwiftUI never registers | ||
| /// it with the enclosing bars. These tests pin the UIKit contract that makes | ||
| /// the soft scroll edge effect render: once hosted under a navigation and tab | ||
| /// bar controller, the table must be each container's content scroll view. | ||
| @MainActor | ||
| @Suite struct WorkspaceListScrollEdgeEffectTests { | ||
| @Test func hostedTableDrivesNavigationAndTabBarScrollEdgeEffects() throws { | ||
| guard #available(iOS 26.0, *) else { return } | ||
| let fixture = Fixture() | ||
|
|
||
| #expect(fixture.content.contentScrollView(for: .top) === fixture.tableView) | ||
| #expect(fixture.navigation.contentScrollView(for: .bottom) === fixture.tableView) | ||
| } | ||
|
|
||
| @Test func tableLeavingTheWindowReleasesBarRegistrations() throws { | ||
| guard #available(iOS 26.0, *) else { return } | ||
| let fixture = Fixture() | ||
|
|
||
| fixture.tableView.removeFromSuperview() | ||
|
|
||
| #expect(fixture.content.contentScrollView(for: .top) == nil) | ||
| #expect(fixture.navigation.contentScrollView(for: .bottom) == nil) | ||
| } | ||
|
|
||
| /// The controller chain can assemble incrementally: the navigation | ||
| /// controller can host the table before it joins a tab bar controller. | ||
| /// Whichever lifecycle path fires for the move (UIKit container | ||
| /// attachment always relocates the child's view, so `didMoveToWindow` | ||
| /// and layout passes both run), the bottom edge must end registered. | ||
| /// UIKit's hierarchy-consistency check forbids re-parenting a controller | ||
| /// whose view stays in a foreign hierarchy, so a window-stable variant of | ||
| /// this scenario is not constructible; the window-stable layout-retry | ||
| /// path is covered by | ||
| /// ``survivingTableReclaimsRegistrationAfterOwnerDeparts()``. | ||
| @Test func lateTabControllerAttachmentStillRegistersBottomEdge() throws { | ||
| guard #available(iOS 26.0, *) else { return } | ||
| let tableView = WorkspaceListUITableView(frame: .zero, style: .plain) | ||
| let content = UIViewController() | ||
| content.view.addSubview(tableView) | ||
| let navigation = UINavigationController(rootViewController: content) | ||
| let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) | ||
| window.rootViewController = navigation | ||
| window.isHidden = false | ||
| window.layoutIfNeeded() | ||
| content.view.layoutIfNeeded() | ||
| #expect(content.contentScrollView(for: .top) === tableView) | ||
| #expect(navigation.contentScrollView(for: .bottom) == nil) | ||
|
|
||
| let tabs = UITabBarController() | ||
| window.rootViewController = tabs | ||
| tabs.viewControllers = [navigation] | ||
| window.layoutIfNeeded() | ||
| tableView.setNeedsLayout() | ||
| tableView.layoutIfNeeded() | ||
|
|
||
| #expect(content.contentScrollView(for: .top) === tableView) | ||
| #expect(navigation.contentScrollView(for: .bottom) === tableView) | ||
| } | ||
|
|
||
| /// While two live tables coexist (SwiftUI transition overlap), the later | ||
| /// one must not steal the registration: ownership would otherwise become | ||
| /// layout-order dependent and thrash on every pass. | ||
| @Test func coexistingTablesDoNotStealRegistrationFromEachOther() throws { | ||
| guard #available(iOS 26.0, *) else { return } | ||
| let fixture = Fixture() | ||
| let second = WorkspaceListUITableView(frame: .zero, style: .plain) | ||
|
|
||
| fixture.content.view.addSubview(second) | ||
| second.layoutIfNeeded() | ||
| #expect(fixture.content.contentScrollView(for: .top) === fixture.tableView) | ||
|
|
||
| second.setNeedsLayout() | ||
| second.layoutIfNeeded() | ||
| fixture.tableView.setNeedsLayout() | ||
| fixture.tableView.layoutIfNeeded() | ||
|
|
||
| #expect(fixture.content.contentScrollView(for: .top) === fixture.tableView) | ||
| #expect(fixture.navigation.contentScrollView(for: .bottom) === fixture.tableView) | ||
| } | ||
|
|
||
| /// When the owning table departs it clears the edges and nudges the | ||
| /// waiting table, which claims them on the next layout flush. The test | ||
| /// only flushes pending window layout; the survivor is never marked | ||
| /// dirty by hand, so a missing wake fails the assertions. | ||
| @Test func survivingTableReclaimsRegistrationAfterOwnerDeparts() throws { | ||
| guard #available(iOS 26.0, *) else { return } | ||
| let fixture = Fixture() | ||
| let second = WorkspaceListUITableView(frame: .zero, style: .plain) | ||
| fixture.content.view.addSubview(second) | ||
| second.layoutIfNeeded() | ||
| #expect(fixture.content.contentScrollView(for: .top) === fixture.tableView) | ||
|
|
||
| fixture.tableView.removeFromSuperview() | ||
| #expect(fixture.content.contentScrollView(for: .top) == nil) | ||
|
|
||
| fixture.window.layoutIfNeeded() | ||
|
|
||
| #expect(fixture.content.contentScrollView(for: .top) === second) | ||
| #expect(fixture.navigation.contentScrollView(for: .bottom) === second) | ||
| } | ||
|
|
||
| /// Table hosted under `UITabBarController > UINavigationController > | ||
| /// content`, mirroring the shell's TabView + NavigationStack chrome. | ||
| @MainActor | ||
| private struct Fixture { | ||
| let tableView: WorkspaceListUITableView | ||
| let content: UIViewController | ||
| let navigation: UINavigationController | ||
| let tabs: UITabBarController | ||
| let window: UIWindow | ||
|
|
||
| init() { | ||
| tableView = WorkspaceListUITableView(frame: .zero, style: .plain) | ||
| content = UIViewController() | ||
| content.view.addSubview(tableView) | ||
| navigation = UINavigationController(rootViewController: content) | ||
| tabs = UITabBarController() | ||
| tabs.viewControllers = [navigation] | ||
| window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) | ||
| window.rootViewController = tabs | ||
| window.isHidden = false | ||
| window.layoutIfNeeded() | ||
| content.view.layoutIfNeeded() | ||
| } | ||
| } | ||
| } | ||
| #endif |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.