diff --git a/CHANGELOG.md b/CHANGELOG.md index da5e060..ab8342e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,26 @@ # Change Log All notable changes to this project will be documented in this file. +## 3.0.0 + +### Breaking Changes + +* Minimum deployment target raised to iOS 18. iOS 17 is no longer supported. +* `HeaderContext` converted from a struct to an `@Observable` class to address performance issues related to SwiftData queries. The observable class shields content views from re-performing queries and re-evaluating `body` on every frame while scrolling. If you stored `HeaderContext` (or its typealiases `MaterialTabsHeaderContext` / `StickyHeaderContext`) by value, you will need to update to reference semantics. +* #19 `MaterialTabsScroll` joint scroll position API replaced with iOS 18 `ScrollPosition`. The old `scrollAnchor` parameter is replaced by optional `scrollPosition` and `anchor` bindings for joint scroll position manipulation between the library and client code. + +### Improvements + +* #25 `MaterialTabBar` now supports an `alignment` parameter (`.leading`, `.center`, `.trailing`) for controlling horizontal positioning of self-sized tabs when `fillAvailableSpace` is `false`. +* #25 New `MaterialAccessoryTabBar` component for adding optional leading and trailing accessory views alongside tab selectors. Accessories scroll horizontally with the tabs. +* #25 `TabBarModel` and `HeaderModel` are now public, enabling fully custom tab bar implementations via the environment. + +## 2.0.7 + +### Improvements + +* Add context-free initializers to `MaterialTabsScroll` and `StickyHeaderScroll`. The existing initializers pass a `MaterialTabsScrollContext` (or `StickyHeaderScrollContext`) to the content view builder, which includes the content offset. Because the content offset changes on every frame during scrolling, this causes the content view `body` to be re-evaluated continuously. The new initializers omit the context, allowing the content to be shielded from these unnecessary re-evaluations via an internal `ContentWrapperView` with `Equatable` conformance. If your content does not need the context, prefer the new initializers for better scroll performance. + ## 2.0.6 ### Fixes diff --git a/Demo/Demo/Preview Content/TestMaterialTabsScroll.swift b/Demo/Demo/Preview Content/TestMaterialTabsScroll.swift index 1c80f95..647ebef 100644 --- a/Demo/Demo/Preview Content/TestMaterialTabsScroll.swift +++ b/Demo/Demo/Preview Content/TestMaterialTabsScroll.swift @@ -18,8 +18,8 @@ struct TestMaterialTabsScroll: View { // MARK: - Variables @State private var selectedTab = 0 - @State private var scrollItem: Int? - @State private var scrollUnitPoint: UnitPoint = .top + @State private var scrollPosition = ScrollPosition(idType: Int.self) + @State private var scrollAnchor: UnitPoint? = .top // MARK: - Body @@ -30,7 +30,19 @@ struct TestMaterialTabsScroll: View { Text("Title").frame(height: titleHeight) }, headerTabBar: { context in - Text("Tab Bar").frame(height: tabBarHeight) + HStack(spacing: 0) { + ForEach(0..<2) { tab in + Button { + selectedTab = tab + } label: { + Text("Tab \(tab)") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(selectedTab == tab ? Color.blue.opacity(0.2) : Color.clear) + } + } + } + .frame(height: tabBarHeight) }, headerBackground: { _ in Color.yellow.opacity(0.25) @@ -38,20 +50,32 @@ struct TestMaterialTabsScroll: View { ) { MaterialTabsScroll( tab: 0, - reservedItem: -1, - scrollItem: $scrollItem, - scrollUnitPoint: $scrollUnitPoint + scrollPosition: $scrollPosition, + anchor: $scrollAnchor ) { _ in LazyVStack(spacing: 0) { ForEach(0..<25) { index in VStack(spacing: 0) { Rectangle().fill(.black.opacity(0.2)).frame(height: 1) Spacer() - Button("Tap Row \(index)") { - scrollUnitPoint = .top - scrollItem = index + HStack { + Text("Row \(index)") + Spacer() + Button("Top") { + withAnimation { + scrollAnchor = .top + scrollPosition.scrollTo(id: index, anchor: .top) + } + } + Button("Bottom") { + withAnimation { + scrollAnchor = .bottom + scrollPosition.scrollTo(id: index, anchor: .bottom) + } + } } .buttonStyle(.bordered) + .padding(.horizontal) Spacer() } .frame(height: rowHeight) @@ -60,8 +84,22 @@ struct TestMaterialTabsScroll: View { } .scrollTargetLayout() } + .materialTabItem(tab: 0, label: .secondary("Tab 0")) + MaterialTabsScroll(tab: 1) { _ in + LazyVStack(spacing: 0) { + ForEach(0..<25) { index in + VStack(spacing: 0) { + Rectangle().fill(.black.opacity(0.2)).frame(height: 1) + Spacer() + Text("Tab 1 — Row \(index)") + Spacer() + } + .frame(height: rowHeight) + } + } + } + .materialTabItem(tab: 1, label: .secondary("Tab 1")) } - .animation(.default, value: scrollItem) } } diff --git a/Package.swift b/Package.swift index 100de98..980aa93 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,11 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "SwiftUIMaterialTabs", - platforms: [.iOS(.v17)], + platforms: [.iOS(.v18)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( diff --git a/README.md b/README.md index 3520878..cf1e7a4 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # SwiftUIMaterialTabs ![GitHub Release](https://img.shields.io/github/v/release/swiftkickmobile/SwiftUIMaterialTabs) -![iOS 17.0+](https://img.shields.io/badge/iOS-17.0%2B-yellow.svg) -![Xcode 15.0+](https://img.shields.io/badge/Xcode-15.0%2B-blue.svg) -![Swift 5.9+](https://img.shields.io/badge/Swift-5.9%2B-purple) +![iOS 18.0+](https://img.shields.io/badge/iOS-18.0%2B-yellow.svg) +![Xcode 16.0+](https://img.shields.io/badge/Xcode-16.0%2B-blue.svg) +![Swift 6.0+](https://img.shields.io/badge/Swift-6.0%2B-purple) ![GitHub License](https://img.shields.io/github/license/swiftkickmobile/SwiftUIMaterialTabs) ## Overview @@ -17,7 +17,7 @@ SwiftUIMaterialTabs is a pure SwiftUI [Material 3-style tabs](https://m3.materia SwiftUIMaterialTabs is installed through Swift Package Manager. In Xcode, navigate to `File | Add Package Dependency...`, paste the URL of this repository in the search field, and click "Add Package". -In your source file, import `MaterialTabs` to access the library. +In your source file, import `SwiftUIMaterialTabs` to access the library. The main components you'll use will depend on the use case: Material Tabs or Sticky Headers. The APIs are almost identical, with the main difference being that Material Tabs components have an extra `Tab` generic parameter and `MaterialTabs` requires an additional view builder for the tab bar. @@ -29,7 +29,7 @@ The main components you'll use will depend on the use case: Material Tabs or Sti | The context passed to scroll view builders with useful metrics, such as the safe content height under the header. | `MaterialTabsScrollContext` | `StickyHeaderScrollContext` | | The tab bar. | `MaterialTabBar` | n/a | -These and additional coponents are covered in the Material Tabs and Sticky Headers sections (jump to [Sticky Headers](#sticky-headers)). +These and additional components are covered in the Material Tabs and Sticky Headers sections (jump to [Sticky Headers](#sticky-headers)). ## Material Tabs @@ -48,7 +48,7 @@ struct BasicTabView: View { @State var selectedTab: Tab = .first var body: some View { - // The main conainer view. + // The main container view. MaterialTabs( // A binding to the currently selected tab. selectedTab: $selectedTab, @@ -67,20 +67,36 @@ struct BasicTabView: View { // The background spans the entire header and top safe area. Color.yellow }, - // The tab contents. + // The tab contents. Scrollable content must be wrapped in MaterialTabsScroll. content: { - Text("First Tab Content") - // Identify tabs using the `.materialTabItem()` view modifier. - .materialTabItem( - tab: Tab.first, - // Using Material 3 primary tab style. - label: .primary("First", icon: Image(systemName: "car")) - ) - Text("Second Tab Content") - .materialTabItem( - tab: Tab.second, - label: .primary("Second", icon: Image(systemName: "sailboat")) - ) + MaterialTabsScroll(tab: Tab.first) { _ in + LazyVStack { + ForEach(0..<20, id: \.self) { index in + Text("First Tab — Row \(index)") + .padding() + } + } + .scrollTargetLayout() + } + // Identify tabs using the `.materialTabItem()` view modifier. + .materialTabItem( + tab: Tab.first, + // Using Material 3 primary tab style. + label: .primary("First", icon: Image(systemName: "car")) + ) + MaterialTabsScroll(tab: Tab.second) { _ in + LazyVStack { + ForEach(0..<20, id: \.self) { index in + Text("Second Tab — Row \(index)") + .padding() + } + } + .scrollTargetLayout() + } + .materialTabItem( + tab: Tab.second, + label: .primary("Second", icon: Image(systemName: "sailboat")) + ) } ) } @@ -91,7 +107,7 @@ struct BasicTabView: View { `MaterialTabBar` is a horizontally scrolling tab bar that supports Material 3 primary and secondary tab styles or custom tab selectors. You specify the tab selector labels by applying the `materialTabItem(tab:label:)` view modifier to your top-level tab contents. -`MaterialTabBar` has two options for hoziontal sizing: `.equalWidth` and `.proportionalWidth`. +`MaterialTabBar` has two options for horizontal sizing: `.equalWidth` and `.proportionalWidth`. ````swift MaterialTabBar(selectedTab: $selectedTab, sizing: .equalWidth, context: context) @@ -100,9 +116,64 @@ MaterialTabBar(selectedTab: $selectedTab, sizing: .proportionalWidth, context: c With `.equalWidth`, all tabs will be the width of the largest tab selector. With `.proportional`, tabs will be sized horizontally to fit. In either case, selector labels will expand to fill the available width of the tab bar. If there isn't enough space, the tab bar scrolls. +By default, tabs expand to fill the available width. Set `fillAvailableSpace: false` to use self-sized tabs, and use `alignment` to control their horizontal position: + +````swift +MaterialTabBar( + selectedTab: $selectedTab, + sizing: .proportionalWidth, + fillAvailableSpace: false, + alignment: .leading, + context: context +) +```` + +### `MaterialAccessoryTabBar` + +`MaterialAccessoryTabBar` is a variant of `MaterialTabBar` that supports optional leading and trailing accessory views alongside the tab selectors. The accessories scroll horizontally together with the tabs. + +````swift +MaterialAccessoryTabBar( + selectedTab: $selectedTab, + sizing: .proportionalWidth, + context: context, + leading: { + Image(systemName: "line.3.horizontal.decrease.circle") + .padding(.horizontal, 12) + }, + trailing: { + Image(systemName: "plus.circle.fill") + .padding(.horizontal, 12) + } +) +```` + +Convenience initializers are provided for leading-only or trailing-only accessories. + +### Custom Tab Bars + +For full control over tab bar layout, you can build your own tab bar using `TabBarModel` and `HeaderModel`, which are available in the environment within `MaterialTabs`. This is useful when `MaterialTabBar` and `MaterialAccessoryTabBar` don't fit your design requirements. + +````swift +struct CustomTabBar: View { + @Environment(TabBarModel.self) private var tabBarModel + @Environment(HeaderModel.self) private var headerModel + + var body: some View { + HStack { + ForEach(tabBarModel.tabs, id: \.self) { tab in + tabBarModel.labels[tab]?(tab, headerModel.headerContext, { + headerModel.selected(tab: tab) + }) + } + } + } +} +```` + ### `MaterialTabItemModifier` -The `MaterialTabItemModifier` view modifier is used to identify and configure tabs for the tab bar. It is conceptually similar to a combination of the `tag()` and `tagitem()` view modifiers used with a standard `TabView` +The `MaterialTabItemModifier` view modifier is used to identify and configure tabs for the tab bar. It is conceptually similar to a combination of the `tag()` and `tabItem()` view modifiers used with a standard `TabView`. There are two built-in selector labels: `PrimaryTab` and `SecondaryTab`. You don't typically create these directly, but specify them when applying `.materialTabItem()` to your tab contents: @@ -146,11 +217,12 @@ Scrollable tab content must be contained within a `MaterialTabsScroll`, a lightw content: { MaterialTabsScroll(tab: Tab.first) { _ in LazyVStack { - ForEach(0..<10) { index in - Text("Row \(index) + ForEach(0..<10, id: \.self) { index in + Text("Row \(index)") .padding() } } + .scrollTargetLayout() } .materialTabItem(tab: Tab.first, label: .secondary("First")) } @@ -158,20 +230,27 @@ content: { When this component is used, Material Tabs automatically maintains consistency of scroll position across tabs as the header is collapsed and expanded. -Joint manipulation of the scroll position is supported if you need it. You supply `scrollItem` and `scrollUnitPoint` bindings and `MaterialTabsScroll` applies the `scrollPosition()` modifier internally. You are free to set the `scrollTargetLayout()` view modifier in your content where appropriate. +For sticky header scroll effects (fade, shrink, parallax, etc.), see the [Sticky Headers](#sticky-headers) section — those effects apply equally to `MaterialTabs` and `StickyHeader`. + +Joint manipulation of the scroll position is supported if you need it. You supply a `ScrollPosition` binding and an optional `anchor` binding, and `MaterialTabsScroll` applies the `scrollPosition()` modifier internally. You are free to set the `scrollTargetLayout()` view modifier in your content where appropriate. ````swift -@State var scrollItem: Int? -@State var scrollUnitPoint: UnitPoint = .top +@State var scrollPosition = ScrollPosition(idType: Int.self) +@State var scrollAnchor: UnitPoint? = nil ... content: { - MaterialTabsScroll(tab: Tab.first, reservedItem: -1, scrollItem: $scrollItem, scrollUnitPoint: $scrollUnitPoint) { _ in + MaterialTabsScroll( + tab: Tab.first, + scrollPosition: $scrollPosition, + anchor: $scrollAnchor + ) { _ in LazyVStack(spacing: 0) { ForEach(0..<10) { index in Text("Row \(index)") .padding() + .id(index) } } .scrollTargetLayout() @@ -180,14 +259,24 @@ content: { } ```` -One nuance of the `scrollPosition()` view modifier is that, if you need to precisely manipulate the scroll position, you must know the height of the view being scrolled. Therefore, in order for Material Tabs to achieve precise control, you are required to supply a `reservedItem` identifier that Material Tabs will use to embed its own hidden view in the scroll. We couldn't think of another way to do this while staying "pure SwiftUI". +With `ScrollPosition`, you can programmatically scroll to a specific item or edge: -It is worth noting here, because it is not completely obvious, that the formula for calculating the a unit point for `scrollPosition()` seems to be: +````swift +// Scroll to a specific item +withAnimation { + scrollAnchor = .top + scrollPosition.scrollTo(id: 5, anchor: .top) +} -```` -unitPoint = (desiredContentOffset) / (scrollViewHeight - verticalSafeArea - verticalContentPadding - viewHeight) +// Scroll to an edge +scrollPosition.scrollTo(edge: .top) ```` +> **Important:** When scrolling to a specific item with an anchor, the `anchor` binding must +> be updated to match the `scrollTo(id:anchor:)` value. The `.scrollPosition()` modifier's +> anchor controls how visible items are repositioned — if it doesn't match, visible items +> won't move. Always update both together as shown above. + It should be noted that `MaterialTabsScroll` inserts a spacer into the scroll to push your content below the header. ## Sticky Headers @@ -200,7 +289,7 @@ The basic usage is the same as `MaterialTabs` without the tab bar: struct BasicStickyHeaderView: View { var body: some View { - // The main conainer view. + // The main container view. StickyHeader( // A view builder for the header title that takes a `StickyHeaderContext`. This can be anything. headerTitle: { context in @@ -212,9 +301,9 @@ struct BasicStickyHeaderView: View { // The background spans the entire header and top safe area. Color.yellow }, - // The tab contents. + // The scroll content. content: { - StickyHeaderScroll() { _ in + StickyHeaderScroll { _ in LazyVStack(spacing: 0) { ForEach(0..<10) { index in Text("Row \(index)") @@ -231,11 +320,11 @@ struct BasicStickyHeaderView: View { ### `StickyHeaderScroll` -`StickyHeaderScroll` is completely analogous to [`MaterialTabsScroll`](#materialtabsscroll). +`StickyHeaderScroll` is a lightweight `ScrollView` wrapper for sticky header effects, similar to [`MaterialTabsScroll`](#materialtabsscroll) but without tab-based scroll position sync. It does not provide a `ScrollPosition` binding — if you need programmatic scroll control, manage it on your own `ScrollView` outside the library. ### `HeaderStyleModifier` -The `HeaderStyleModifier` view modifier works with the `HeaderStyle` protocol to implement sticky header scroll effects, such as fade, shrink and parallax. You may apply different `headerStyle(context:)` to modifiers to different header elements or apply multiple styles to a single element to achieve unique effects. +The `HeaderStyleModifier` view modifier works with the `HeaderStyle` protocol to implement sticky header scroll effects, such as fade, shrink and parallax. You may apply `headerStyle(_:context:)` to different header elements or apply multiple styles to a single element to achieve unique effects. To have the title fade out as it scrolls off screen: @@ -262,7 +351,18 @@ Image(.coolBackground) .headerStyle(ParallaxHeaderStyle(), context: context) ```` -Under the hood, these styles are using parameters provided in the `StickyHeaderHeaderContext`/`MaterialTabsHeaderContext` to adjust `.scaleEffect()`, `.offset()`, and `.opacity()`. You may implement your own styles by adopting `HeaderStyle` or manipulate your header views directly. +Under the hood, these styles are using parameters provided in the `StickyHeaderContext`/`MaterialTabsContext` to adjust `.scaleEffect()`, `.offset()`, and `.opacity()`. You may implement your own styles by adopting `HeaderStyle` or manipulate your header views directly. + +For example, you can use `unitOffset` to create a custom blur effect that increases as the header collapses: + +````swift +Image(.coolBackground) + .resizable() + .aspectRatio(contentMode: .fill) + .blur(radius: 10 * max(0, context.unitOffset)) +```` + +The `unitOffset` property ranges from 0 (fully expanded) to 1 (fully collapsed), with negative values during rubber-banding. Use `offset` and `maxOffset` for absolute values. ### `MinTitleHeightModifier` @@ -271,8 +371,8 @@ The `MinTitleHeightModifier` view modifier can be used to inform the library wha To make a bottom title element stick at the top: ````swift -VStack() { - Text("Top Title Element"). +VStack { + Text("Top Title Element") .padding() Text("Bottom Title Element") .padding() @@ -285,8 +385,8 @@ The use of the `.content()` option causes the library to measure the height of t The `FixedHeaderStyle` header style can be used to make a top title element stick: ````swift -VStack() { - Text("Top Title Element"). +VStack { + Text("Top Title Element") .padding() .headerStyle( ShrinkHeaderStyle( @@ -311,4 +411,4 @@ We build high quality apps for clients! [Get in touch](http://www.swiftkickmobil ## License -SwiftMessages is distributed under the MIT license. [See LICENSE](./LICENSE.md) for details. +SwiftUIMaterialTabs is distributed under the MIT license. [See LICENSE](./LICENSE.md) for details. diff --git a/Sources/SwiftUIMaterialTabs/Internal/HeaderContext.swift b/Sources/SwiftUIMaterialTabs/Internal/HeaderContext.swift index ac340e2..62c2384 100644 --- a/Sources/SwiftUIMaterialTabs/Internal/HeaderContext.swift +++ b/Sources/SwiftUIMaterialTabs/Internal/HeaderContext.swift @@ -16,7 +16,8 @@ import SwiftUI /// Although the collapsed state of the header is just an offset, applying the `headerStyle()` view modifier to header elements can give the /// impression of shrinking, fading, parallax, etc. All of these effects are achived by manipulating the views based on the `HeaderContext` /// values provided to the various header view builders. You may also manipulate header elements directly without using `headerStyle()` if you wish. -public struct HeaderContext: Equatable where Tab: Hashable { +@Observable +public class HeaderContext where Tab: Hashable { // MARK: - API @@ -70,9 +71,7 @@ public struct HeaderContext: Equatable where Tab: Hashable { } /// The minimum effective height of the header in the fully collapsed position. - public var minTotalHeight: CGFloat { - tabBarHeight + minTitleHeight - } + public var minTotalHeight: CGFloat { tabBarHeight + minTitleHeight } public init(selectedTab: Tab) { self.selectedTab = selectedTab diff --git a/Sources/SwiftUIMaterialTabs/Internal/HeaderModel.swift b/Sources/SwiftUIMaterialTabs/Internal/HeaderModel.swift index 3da6d1d..9e138fb 100644 --- a/Sources/SwiftUIMaterialTabs/Internal/HeaderModel.swift +++ b/Sources/SwiftUIMaterialTabs/Internal/HeaderModel.swift @@ -4,78 +4,81 @@ import SwiftUI +/// The model that manages header state, including offset tracking and tab selection. This class is available in the +/// environment within `MaterialTabs` to support building custom tab bars. Read it with `@Environment(HeaderModel.self)`. +/// +/// For custom tab bars, the key members are: +/// - ``headerContext``: The header context to pass to tab label closures from `TabBarModel`. +/// - ``selected(tab:)``: Call this when a tab is tapped to notify the library of the selection. @MainActor -class HeaderModel: ObservableObject where Tab: Hashable { +@Observable +public final class HeaderModel where Tab: Hashable { // MARK: - API - struct State: Equatable { - var headerContext: HeaderContext + /// The header context containing metrics for sticky header effects and the current selected tab. + public let headerContext: HeaderContext - /// The height reported by the geometry reader. Includes the additional safe area padding we apply. - var height: CGFloat = 0 + /// The height reported by the geometry reader. Includes the additional safe area padding we apply. + var height: CGFloat = 0 - var tabsRegistered: Bool = false + var tabsRegistered: Bool = false - /// The height factoring in the additional safe area padding we apply. - var safeHeight: CGFloat { - height - headerContext.minTotalHeight - } - - var config: MaterialTabsConfig = MaterialTabsConfig() + /// The height factoring in the additional safe area padding we apply. + var safeHeight: CGFloat { + height - headerContext.minTotalHeight } - @Published fileprivate(set) var state: State + var config: MaterialTabsConfig = MaterialTabsConfig() init(selectedTab: Tab) { - _state = Published( - wrappedValue: State(headerContext: HeaderContext(selectedTab: selectedTab)) - ) + self.headerContext = HeaderContext(selectedTab: selectedTab) } func configChanged(_ config: MaterialTabsConfig) { - state.config = config + self.config = config } func sizeChanged(_ size: CGSize) { - state.height = size.height - state.headerContext.width = size.width + height = size.height + headerContext.width = size.width } func titleHeightChanged(_ height: CGFloat) { - state.headerContext.titleHeight = height + headerContext.titleHeight = height } func minTitleHeightChanged(_ metric: MinTitleHeightPreferenceKey.Metric) { - state.headerContext.minTitleMetric = metric + headerContext.minTitleMetric = metric } func tabBarHeightChanged(_ height: CGFloat) { - state.headerContext.tabBarHeight = height + headerContext.tabBarHeight = height } - func selected(tab: Tab) { + /// Notify the library that a tab was selected. Call this from custom tab bar implementations when a tab is tapped. + public func selected(tab: Tab) { hasScrolledSinceSelected = false - self.state.headerContext.selectedTab = tab + headerContext.selectedTab = tab } func safeAreaChanged(_ safeArea: EdgeInsets) { - self.state.headerContext.safeArea = safeArea + headerContext.safeArea = safeArea } func animationNamespaceChanged(_ animationNamespace: Namespace.ID) { - state.headerContext.animationNamespace = animationNamespace + headerContext.animationNamespace = animationNamespace } - func tabsRegistered() { + func onTabsRegistered() { Task { - guard !state.tabsRegistered else { return } - state.tabsRegistered = true + guard !tabsRegistered else { return } + self.tabsRegistered = true } } func contentOffsetChanged(_ contentOffset: CGFloat) { - state.headerContext.contentOffset = contentOffset + headerContext.contentOffset = contentOffset } // MARK: - Constants @@ -93,28 +96,28 @@ class HeaderModel: ObservableObject where Tab: Hashable { /// In the basic case, the header offset matches the scroll view up calculated max offset. However, in a multi-tab environment, scrolling on another tab /// can change the header offset, introducing edge cases that need to be handled. func scrolled(tab: Tab, contentOffset: CGFloat, deltaContentOffset: CGFloat) { - guard tab == state.headerContext.selectedTab else { return } - switch state.config.crossTabSyncMode { + guard tab == headerContext.selectedTab else { return } + switch config.crossTabSyncMode { case .resetTitleOnScroll where !hasScrolledSinceSelected: withAnimation(.snappy(duration: 0.3)) { - state.headerContext.offset = min(state.headerContext.maxOffset, contentOffset) + headerContext.offset = min(headerContext.maxOffset, contentOffset) } default: - state.headerContext.contentOffset = contentOffset + headerContext.contentOffset = contentOffset // When scrolling down (a.k.a. swiping up), the header offset matches the scroll view until it reaches the // max offset, at which point it is fully collapsed. if deltaContentOffset > 0 { // If the scroll view offset is less than the max offset, then the scroll and header offsets should // match. - if contentOffset < state.headerContext.maxOffset { - state.headerContext.offset = contentOffset + if contentOffset < headerContext.maxOffset { + headerContext.offset = contentOffset } // However, if the scroll view is past the max offset, the header must move by the same amount until // it reaches the max offset. else { - state.headerContext.offset = min( - state.headerContext.offset + deltaContentOffset, - state.headerContext.maxOffset + headerContext.offset = min( + headerContext.offset + deltaContentOffset, + headerContext.maxOffset ) } // When scrolling up (a.k.a. swiping down), the header offset remains fixed unless it needs to change to @@ -123,8 +126,8 @@ class HeaderModel: ObservableObject where Tab: Hashable { } else { // If the scroll view's offset is less than the header's offset, the header must match the scroll view // offset to avoid separation. - if contentOffset < state.headerContext.offset { - state.headerContext.offset = contentOffset + if contentOffset < headerContext.offset { + headerContext.offset = contentOffset } } } diff --git a/Sources/SwiftUIMaterialTabs/Internal/HeaderView.swift b/Sources/SwiftUIMaterialTabs/Internal/HeaderView.swift index ab5cf2e..968c2f4 100644 --- a/Sources/SwiftUIMaterialTabs/Internal/HeaderView.swift +++ b/Sources/SwiftUIMaterialTabs/Internal/HeaderView.swift @@ -28,7 +28,7 @@ struct HeaderView: View where Title: View, TabBa @ViewBuilder private let title: (HeaderContext) -> Title @ViewBuilder private let tabBar: (HeaderContext) -> TabBar @ViewBuilder private let background: (HeaderContext) -> Background - @EnvironmentObject private var headerModel: HeaderModel + @Environment(HeaderModel.self) private var headerModel @Namespace private var animationNamespace // MARK: - Body @@ -47,7 +47,7 @@ struct HeaderView: View where Title: View, TabBa // Clip for image backgrounds that use aspect fill .clipped() } - .offset(CGSize(width: 0, height: -max(headerModel.state.headerContext.offset, 0))) + .offset(CGSize(width: 0, height: -max(headerModel.headerContext.offset, 0))) .animation(.default, value: context.selectedTab) .onChange(of: animationNamespace, initial: true) { headerModel.animationNamespaceChanged(animationNamespace) diff --git a/Sources/SwiftUIMaterialTabs/Internal/MaterialTabBarContent.swift b/Sources/SwiftUIMaterialTabs/Internal/MaterialTabBarContent.swift new file mode 100644 index 0000000..93deea5 --- /dev/null +++ b/Sources/SwiftUIMaterialTabs/Internal/MaterialTabBarContent.swift @@ -0,0 +1,129 @@ +// +// Created by Timothy Moose on 2/11/26. +// + +import SwiftUI + +/// Internal shared view that renders the scrollable tab bar content. Used by both `MaterialTabBar` +/// and `MaterialAccessoryTabBar` to avoid duplicating tab layout logic. +/// +/// Optional leading and trailing accessory views are placed inside the scroll view so they scroll with the tabs. +struct MaterialTabBarContent: View where Tab: Hashable { + + // MARK: - API + + init( + selectedTab: Binding, + sizing: MaterialTabBar.Sizing, + spacing: CGFloat, + fillAvailableSpace: Bool, + alignment: MaterialTabBar.Alignment, + leading: Leading, + trailing: Trailing + ) { + _selectedTab = selectedTab + _selectedTabScroll = State(initialValue: selectedTab.wrappedValue) + self.sizing = sizing + self.spacing = spacing + self.fillAvailableSpace = fillAvailableSpace + self.alignment = alignment + self.leading = leading + self.trailing = trailing + } + + // MARK: - Constants + + // MARK: - Variables + + @Binding private var selectedTab: Tab + @State private var selectedTabScroll: Tab? + private let sizing: MaterialTabBar.Sizing + private let spacing: CGFloat + private let fillAvailableSpace: Bool + private let alignment: MaterialTabBar.Alignment + private let leading: Leading + private let trailing: Trailing + @Environment(TabBarModel.self) private var tabBarModel + @Environment(HeaderModel.self) private var headerModel + @State private var height: CGFloat = 0 + + // MARK: - Body + + var body: some View { + GeometryReader { proxy in + ScrollView(.horizontal) { + HStack(spacing: 0) { + leading + TabBarLayout( + fittingWidth: proxy.size.width, + sizing: sizing, + spacing: spacing, + fillAvailableSpace: fillAvailableSpace + ) { + ForEach(tabBarModel.tabs, id: \.self) { tab in + tabBarModel.labels[tab]?( + tab, + headerModel.headerContext, + { + headerModel.selected(tab: tab) + } + ) + .id(tab) + } + } + .scrollTargetLayout() + trailing + } + .frame(minWidth: proxy.size.width, alignment: alignment.swiftUIAlignment) + .background { + GeometryReader { proxy in + Color.clear + .preference(key: TabBarHeightPreferenceKey.self, value: proxy.size.height) + } + } + } + .scrollPosition(id: $selectedTabScroll, anchor: .center) + .scrollIndicators(.never) + .scrollBounceBehavior(.basedOnSize) + .animation(.default, value: selectedTabScroll) + } + .frame(height: height) + .onPreferenceChange(TabBarHeightPreferenceKey.self) { height in + self.height = height + } + .onChange(of: selectedTab) { + selectedTabScroll = selectedTab + } + } +} + +extension MaterialTabBar.Alignment { + var swiftUIAlignment: SwiftUI.Alignment { + switch self { + case .leading: .leading + case .center: .center + case .trailing: .trailing + } + } +} + +extension MaterialTabBarContent where Leading == EmptyView, Trailing == EmptyView { + /// Convenience initializer for tab bars without accessories. + init( + selectedTab: Binding, + sizing: MaterialTabBar.Sizing, + spacing: CGFloat, + fillAvailableSpace: Bool, + alignment: MaterialTabBar.Alignment + ) { + self.init( + selectedTab: selectedTab, + sizing: sizing, + spacing: spacing, + fillAvailableSpace: fillAvailableSpace, + alignment: alignment, + leading: EmptyView(), + trailing: EmptyView() + ) + } +} diff --git a/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/MinTitleHeightPreferernceKey.swift b/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/MinTitleHeightPreferernceKey.swift index 349a365..de38cbd 100644 --- a/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/MinTitleHeightPreferernceKey.swift +++ b/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/MinTitleHeightPreferernceKey.swift @@ -11,7 +11,7 @@ struct MinTitleHeightPreferenceKey: PreferenceKey { case unit(CGFloat) } - static var defaultValue: Metric = .absolute(0) + static let defaultValue: Metric = .absolute(0) static func reduce(value: inout Metric, nextValue: () -> Metric) { let next = nextValue() diff --git a/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/ScrollOffsetPreferenceKey.swift b/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/ScrollOffsetPreferenceKey.swift index 4b04c85..7419e13 100644 --- a/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/ScrollOffsetPreferenceKey.swift +++ b/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/ScrollOffsetPreferenceKey.swift @@ -5,7 +5,7 @@ import SwiftUI public struct ScrollOffsetPreferenceKey: PreferenceKey { - public static var defaultValue: CGFloat = 0 + public static let defaultValue: CGFloat = 0 public static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {} } diff --git a/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/TabBarHeightPreferenceKey.swift b/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/TabBarHeightPreferenceKey.swift index 9b52cad..b00d664 100644 --- a/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/TabBarHeightPreferenceKey.swift +++ b/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/TabBarHeightPreferenceKey.swift @@ -5,7 +5,7 @@ import SwiftUI struct TabBarHeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 + static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { let next = nextValue() diff --git a/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/TitleHeightPreferenceKey.swift b/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/TitleHeightPreferenceKey.swift index 2fea33f..abeaaa0 100644 --- a/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/TitleHeightPreferenceKey.swift +++ b/Sources/SwiftUIMaterialTabs/Internal/PreferenceKeys/TitleHeightPreferenceKey.swift @@ -5,7 +5,7 @@ import SwiftUI struct TitleHeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 + static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { let next = nextValue() diff --git a/Sources/SwiftUIMaterialTabs/Internal/ScrollItem.swift b/Sources/SwiftUIMaterialTabs/Internal/ScrollItem.swift deleted file mode 100644 index decec76..0000000 --- a/Sources/SwiftUIMaterialTabs/Internal/ScrollItem.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// Created by Timothy Moose on 1/14/24. -// - -import Foundation - -public enum ScrollItem: Hashable { - case item -} diff --git a/Sources/SwiftUIMaterialTabs/Internal/ScrollModel.swift b/Sources/SwiftUIMaterialTabs/Internal/ScrollModel.swift index 9b6a97a..ff37cfe 100644 --- a/Sources/SwiftUIMaterialTabs/Internal/ScrollModel.swift +++ b/Sources/SwiftUIMaterialTabs/Internal/ScrollModel.swift @@ -5,44 +5,36 @@ import SwiftUI @MainActor -class ScrollModel: ObservableObject where Item: Hashable, Tab: Hashable { +@Observable +class ScrollModel where Tab: Hashable { // MARK: - API - #if canImport(ScrollPosition) - @Published var scrollPosition: ScrollPosition = ScrollPosition(idType: Item.self) - #endif - @Published var scrollItem: Item? - @Published var scrollUnitPoint: UnitPoint = .top - @Published private(set) var appeared = false - @Published private(set) var bottomMargin: CGFloat = 0 - - let scrollMode: ScrollMode + /// Stable identifier for the hidden 1pt reserved item placed outside the lazy stack + /// in `MaterialTabsScroll`. Used as the target for `scrollTo(id:anchor:)` during + /// programmatic offset sync. See the reserved item comment in `MaterialTabsScroll` + /// for why `scrollTo(y:)` cannot be used. + let reservedItemID = UUID() + private(set) var appeared = false + private(set) var bottomMargin: CGFloat = 0 func contentOffsetChanged(_ offset: CGFloat) { let oldContentOffset = contentOffset contentOffset = -offset let deltaOffset = contentOffset - oldContentOffset - switch scrollMode { - case .scrollAnchor: - // This is how we're detecting programatic scroll for lack of a better idea. We don't want to report - // the programatic sync of content offset with the header because it could result in the header moving. - if scrollItem != reservedItem { - headerModel?.scrolled(tab: tab, contentOffset: contentOffset, deltaContentOffset: deltaOffset) - } - case .scrollPosition: - #if canImport(ScrollPosition) - if expectingContentOffset != contentOffset { - headerModel?.scrolled(tab: tab, contentOffset: contentOffset, deltaContentOffset: deltaOffset) - } - #endif + // Only filter out library-internal programmatic scrolls (tab sync). Consumer-initiated + // scrollTo(id:) should still notify the header model so it can collapse/expand. + if !isSyncingWithHeader { + headerModel?.scrolled(tab: tab, contentOffset: contentOffset, deltaContentOffset: deltaOffset) } } - func appeared(headerModel: HeaderModel?) { + func appeared(headerModel: HeaderModel?, scrollPositionBinding: Binding, anchorBinding: Binding) { appeared = true self.headerModel = headerModel - selectedTab = headerModel?.state.headerContext.selectedTab + self.scrollPositionBinding = scrollPositionBinding + self.anchorBinding = anchorBinding + selectedTab = headerModel?.headerContext.selectedTab syncContentOffsetWithHeader(appearance: true) configureBottomMargin() } @@ -54,7 +46,7 @@ class ScrollModel: ObservableObject where Item: Hashable, Tab: Hashab func selectedTabChanged() { guard let headerModel else { return } let wasSelected = selectedTab == tab - selectedTab = headerModel.state.headerContext.selectedTab + selectedTab = headerModel.headerContext.selectedTab let isSelected = selectedTab == tab // Sync content offset when this tab becomes selected if !wasSelected && isSelected { @@ -62,20 +54,6 @@ class ScrollModel: ObservableObject where Item: Hashable, Tab: Hashab } } - #if canImport(ScrollPosition) - func scrollPositionChanged(_ position: ScrollPosition) { - scrollPosition = position - } - #endif - - func scrollItemChanged(_ item: Item?) { - scrollItem = item - } - - func scrollUnitPointChanged(_ unitPoint: UnitPoint) { - scrollUnitPoint = unitPoint - } - func contentSizeChanged(_ contentSize: CGSize) { self.contentSize = contentSize configureBottomMargin() @@ -89,19 +67,8 @@ class ScrollModel: ObservableObject where Item: Hashable, Tab: Hashab configureBottomMargin() } - enum ScrollMode { - case scrollAnchor - case scrollPosition - } - - init( - tab: Tab, - scrollMode: ScrollMode, - reservedItem: Item? - ) { + init(tab: Tab) { self.tab = tab - self.scrollMode = scrollMode - self.reservedItem = reservedItem } // MARK: - Constants @@ -109,10 +76,16 @@ class ScrollModel: ObservableObject where Item: Hashable, Tab: Hashab // MARK: - Variables private let tab: Tab - private let reservedItem: Item? - private var cachedTabsState: HeaderModel.State? + /// The active scroll position binding, provided by the view during `appeared()`. + /// Points to either the consumer's external binding or the view's internal @State. + private var scrollPositionBinding: Binding? + /// The active anchor binding, provided by the view during `appeared()`. + /// Updated during sync to match the calculated UnitPoint for the reserved item. + private var anchorBinding: Binding? + /// Cached header offset from when this tab was last active. + private var cachedOffset: CGFloat? + private var cachedHeight: CGFloat? private weak var headerModel: HeaderModel? - private var expectingContentOffset: CGFloat? private var contentSize: CGSize? private var selectedTab: Tab? { @@ -120,77 +93,73 @@ class ScrollModel: ObservableObject where Item: Hashable, Tab: Hashab guard let headerModel else { return } switch (oldValue == tab, selectedTab == tab) { case (true, false): - // When switching away from this tab, remember the current data so we can - // calculate the delta on return. - cachedTabsState = headerModel.state + cachedOffset = headerModel.headerContext.offset + cachedHeight = headerModel.height default: break } } } private var contentOffset: CGFloat = 0 + /// Set during library-internal programmatic scrolls (syncContentOffsetWithHeader) and cleared + /// after a short delay. Prevents reporting programmatic scroll offsets to the header model. + private var isSyncingWithHeader = false // MARK: Configuring the bottom margin private func configureBottomMargin() { guard let headerModel, let contentSize else { return } - bottomMargin = max(0, headerModel.state.height - contentSize.height - headerModel.state.headerContext.minTotalHeight) + bottomMargin = max(0, headerModel.height - contentSize.height - headerModel.headerContext.minTotalHeight) } // MARK: Adjusting scroll and header state - /// Scrolls to the desired content offset to maintain continuity when switching tabs after a header height change. + /// Scrolls to the desired content offset to maintain continuity when switching tabs + /// after a header height change. /// - /// The formula for calculating the Y component of the unit point for a given item is: + /// Uses `scrollTo(id:anchor:)` targeting a hidden 1pt reserved item rather than + /// `scrollTo(y:)`. See the reserved item comment in `MaterialTabsScroll` for why. /// - /// ```` - /// offset / (scrollView.height - safeArea.top - safeArea.bottom - item.height) - /// ```` + /// The UnitPoint formula positions the reserved item within the visible frame such that + /// the desired content offset is achieved: + /// ``` + /// UnitPoint.y = (maxOffset - contentOffset) / (safeHeight - reservedItemHeight) + /// ``` + /// + /// Important: the `.scrollPosition()` modifier's `anchor` parameter must be updated + /// to match the `scrollTo(id:anchor:)` anchor. If the modifier's anchor is `nil`, + /// the scroll view uses "minimal scroll" behavior and ignores the requested anchor. func syncContentOffsetWithHeader(appearance: Bool) { - guard appeared, let headerModel, - tab == headerModel.state.headerContext.selectedTab || appearance, - headerModel.state.tabsRegistered else { return } + guard appeared, let headerModel, let scrollPositionBinding, + tab == headerModel.headerContext.selectedTab || appearance, + headerModel.tabsRegistered else { return } let deltaHeaderOffset: CGFloat - if let cachedTabsState { - deltaHeaderOffset = headerModel.state.headerContext.offset - cachedTabsState.headerContext.offset + if let cachedOffset { + deltaHeaderOffset = headerModel.headerContext.offset - cachedOffset } else { - deltaHeaderOffset = headerModel.state.headerContext.offset + deltaHeaderOffset = headerModel.headerContext.offset } - cachedTabsState = headerModel.state - switch headerModel.state.config.crossTabSyncMode { + cachedOffset = headerModel.headerContext.offset + cachedHeight = headerModel.height + switch headerModel.config.crossTabSyncMode { case .resetScrollPosition where appearance && - headerModel.state.headerContext.offset < headerModel.state.headerContext.maxOffset && - contentOffset > headerModel.state.headerContext.offset: - contentOffset = headerModel.state.headerContext.offset - // Otherwise, preserve the relative content offset between the header and the scroll view. + headerModel.headerContext.offset < headerModel.headerContext.maxOffset && + contentOffset > headerModel.headerContext.offset: + contentOffset = headerModel.headerContext.offset default: contentOffset = contentOffset + deltaHeaderOffset } - // Update the header context with this tab's content offset during programmatic sync headerModel.contentOffsetChanged(contentOffset) - switch scrollMode { - case .scrollAnchor: - scrollUnitPoint = UnitPoint( - x: UnitPoint.top.x, - y: (headerModel.state.headerContext.maxOffset - contentOffset) / (headerModel.state.safeHeight - 1) - ) - scrollItem = reservedItem - // It is essential to set the scroll item back to `nil` so that we can make - // future scroll adjustments. Placing this in a task is sufficient for the - // above scrolling to occur. - Task { - // Could not find a 100% robust way to detect between a programatic scroll and a user scroll, which we - // need to be able to do to avoid adjusting the header after a programatic scroll. So, sadly, we're going - // this instead and rely on checking the value of `scrollItem`. - try? await Task.sleep(for: .seconds(0.05)) - scrollItem = nil - } - case .scrollPosition: - #if canImport(ScrollPosition) - scrollPosition = ScrollPosition(point: CGPoint(x: 0.5, y: 150)) - #endif + isSyncingWithHeader = true + let unitPointY = (headerModel.headerContext.maxOffset - contentOffset) / (headerModel.safeHeight - 1) + let syncAnchor = UnitPoint(x: UnitPoint.top.x, y: unitPointY) + // The .scrollPosition() modifier's anchor must match the scrollTo anchor for positioning to work. + anchorBinding?.wrappedValue = syncAnchor + scrollPositionBinding.wrappedValue.scrollTo(id: reservedItemID, anchor: syncAnchor) + Task { + try? await Task.sleep(for: .seconds(0.05)) + isSyncingWithHeader = false } } } - diff --git a/Sources/SwiftUIMaterialTabs/Internal/TabBarModel.swift b/Sources/SwiftUIMaterialTabs/Internal/TabBarModel.swift index b40107d..33b0b51 100644 --- a/Sources/SwiftUIMaterialTabs/Internal/TabBarModel.swift +++ b/Sources/SwiftUIMaterialTabs/Internal/TabBarModel.swift @@ -4,18 +4,46 @@ import SwiftUI +/// The model that tracks registered tabs and their label closures. This class is available in the environment +/// within `MaterialTabs` to support building custom tab bars. Read it with `@Environment(TabBarModel.self)`. +/// +/// For most use cases, `MaterialTabBar` is sufficient. Use this model directly when you need full control over +/// tab bar layout, such as adding leading or trailing accessory views alongside tab labels. +/// +/// ```swift +/// struct CustomTabBar: View { +/// @Environment(TabBarModel.self) private var tabBarModel +/// @Environment(HeaderModel.self) private var headerModel +/// +/// var body: some View { +/// HStack { +/// ForEach(tabBarModel.tabs, id: \.self) { tab in +/// tabBarModel.labels[tab]?(tab, headerModel.headerContext, { +/// headerModel.selected(tab: tab) +/// }) +/// } +/// } +/// } +/// } +/// ``` @MainActor -class TabBarModel: ObservableObject where Tab: Hashable { +@Observable +public final class TabBarModel where Tab: Hashable { // MARK: - API - private(set) var tabs: [Tab] = [] - var labels: [Tab: MaterialTabBar.CustomLabel] = [:] + /// The ordered list of registered tabs, in the order they were registered via `.materialTabItem()`. + public private(set) var tabs: [Tab] = [] + + /// The label closures registered for each tab via `.materialTabItem()`. + public var labels: [Tab: MaterialTabBar.CustomLabel] = [:] func register(tab: Tab, @ViewBuilder label: @escaping MaterialTabBar.CustomLabel) { - if !tabs.contains(tab) { - tabs.append(tab) - } + // With @Observable, every mutation triggers re-evaluation of observing views. + // Guard against re-registration to prevent infinite loops when called during + // body evaluation (TabRegisteringView.init). + guard !tabs.contains(tab) else { return } + tabs.append(tab) labels[tab] = label } diff --git a/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialAccessoryTabBar.swift b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialAccessoryTabBar.swift new file mode 100644 index 0000000..813e2a0 --- /dev/null +++ b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialAccessoryTabBar.swift @@ -0,0 +1,271 @@ +// +// Created by Timothy Moose on 2/11/26. +// + +import SwiftUI + +/// A variant of `MaterialTabBar` that supports optional leading and trailing accessory views alongside the tab selectors. +/// The accessories scroll horizontally together with the tabs. +/// +/// For a tab bar without accessories, use `MaterialTabBar` instead. +/// +/// ```swift +/// MaterialAccessoryTabBar( +/// selectedTab: $selectedTab, +/// sizing: .proportionalWidth, +/// context: context, +/// leading: { +/// Button { } label: { Image(systemName: "plus") } +/// .padding(.horizontal) +/// }, +/// trailing: { +/// Button("Edit") { } +/// .padding(.horizontal) +/// } +/// ) +/// ``` +public struct MaterialAccessoryTabBar: View where Tab: Hashable { + + // MARK: - API + + /// See ``MaterialTabBar/Label``. + public typealias Label = MaterialTabBar.Label + + /// See ``MaterialTabBar/Sizing``. + public typealias Sizing = MaterialTabBar.Sizing + + /// See ``MaterialTabBar/Alignment``. + public typealias Alignment = MaterialTabBar.Alignment + + /// See ``MaterialTabBar/CustomLabel``. + public typealias CustomLabel = MaterialTabBar.CustomLabel + + /// Constructs an accessorized tab bar component. + /// - Parameters: + /// - selectedTab: The external tab selection binding. + /// - sizing: The tab selector sizing option. + /// - spacing: The amount of horizontal spacing to use between tab labels. Primary and Secondary tabs should use the default spacing of 0 to + /// form a continuous line across the bottom of the tab bar. + /// - fillAvailableSpace: Applicable when tab labels don't inherently fill the width of the tab bar. When `true` (the default), the label widths are + /// expanded proportionally to fill the tab bar. When `false`, the labels are self-sized and positioned according to `alignment`. + /// - alignment: The horizontal alignment of self-sized tabs when `fillAvailableSpace` is `false`. Has no effect when `fillAvailableSpace` is + /// `true` or when tabs overflow the available width. Defaults to `.center`. + /// - context: The current context value. + /// - leading: A view builder for the leading accessory view. The accessory scrolls with the tabs. + /// - trailing: A view builder for the trailing accessory view. The accessory scrolls with the tabs. + public init( + selectedTab: Binding, + sizing: Sizing = .proportionalWidth, + spacing: CGFloat = 0, + fillAvailableSpace: Bool = true, + alignment: Alignment = .center, + context: MaterialTabsHeaderContext, + @ViewBuilder leading: () -> Leading, + @ViewBuilder trailing: () -> Trailing + ) { + _selectedTab = selectedTab + self.sizing = sizing + self.spacing = spacing + self.fillAvailableSpace = fillAvailableSpace + self.alignment = alignment + self.leading = leading() + self.trailing = trailing() + } + + // MARK: - Constants + + // MARK: - Variables + + @Binding private var selectedTab: Tab + private let sizing: Sizing + private let spacing: CGFloat + private let fillAvailableSpace: Bool + private let alignment: Alignment + private let leading: Leading + private let trailing: Trailing + + // MARK: - Body + + public var body: some View { + MaterialTabBarContent( + selectedTab: $selectedTab, + sizing: sizing, + spacing: spacing, + fillAvailableSpace: fillAvailableSpace, + alignment: alignment, + leading: leading, + trailing: trailing + ) + } +} + +// MARK: - Convenience initializers + +extension MaterialAccessoryTabBar where Trailing == EmptyView { + + /// Constructs an accessorized tab bar with only a leading accessory. + public init( + selectedTab: Binding, + sizing: Sizing = .proportionalWidth, + spacing: CGFloat = 0, + fillAvailableSpace: Bool = true, + alignment: Alignment = .center, + context: MaterialTabsHeaderContext, + @ViewBuilder leading: () -> Leading + ) { + self.init( + selectedTab: selectedTab, + sizing: sizing, + spacing: spacing, + fillAvailableSpace: fillAvailableSpace, + alignment: alignment, + context: context, + leading: leading, + trailing: { EmptyView() } + ) + } +} + +extension MaterialAccessoryTabBar where Leading == EmptyView { + + /// Constructs an accessorized tab bar with only a trailing accessory. + public init( + selectedTab: Binding, + sizing: Sizing = .proportionalWidth, + spacing: CGFloat = 0, + fillAvailableSpace: Bool = true, + alignment: Alignment = .center, + context: MaterialTabsHeaderContext, + @ViewBuilder trailing: () -> Trailing + ) { + self.init( + selectedTab: selectedTab, + sizing: sizing, + spacing: spacing, + fillAvailableSpace: fillAvailableSpace, + alignment: alignment, + context: context, + leading: { EmptyView() }, + trailing: trailing + ) + } +} + +// MARK: - Previews + +private struct AccessoryTabBarPreviewView: View { + + init( + tabs: [MaterialTabBar.Label], + sizing: MaterialTabBar.Sizing = .proportionalWidth, + fillAvailableSpace: Bool = true, + alignment: MaterialTabBar.Alignment = .center, + @ViewBuilder leading: () -> Leading, + @ViewBuilder trailing: () -> Trailing + ) { + self.tabs = tabs + self.sizing = sizing + self.fillAvailableSpace = fillAvailableSpace + self.alignment = alignment + self.leading = leading() + self.trailing = trailing() + } + + private let tabs: [MaterialTabBar.Label] + private let sizing: MaterialTabBar.Sizing + private let fillAvailableSpace: Bool + private let alignment: MaterialTabBar.Alignment + private let leading: Leading + private let trailing: Trailing + @State private var selectedTab: Int = 0 + + var body: some View { + MaterialTabs( + selectedTab: $selectedTab, + headerTabBar: { context in + MaterialAccessoryTabBar( + selectedTab: $selectedTab, + sizing: sizing, + fillAvailableSpace: fillAvailableSpace, + alignment: alignment, + context: context, + leading: { leading }, + trailing: { trailing } + ) + }, + content: { + ForEach(Array(tabs.enumerated()), id: \.offset) { (offset, tab) in + Text("Content for tab \(offset)") + .materialTabItem(tab: offset, label: tab) + } + } + ) + } +} + +#Preview("Leading accessory") { + AccessoryTabBarPreviewView( + tabs: [ + .secondary("First"), + .secondary("Second"), + .secondary("Third"), + ], + leading: { + Image(systemName: "line.3.horizontal.decrease.circle") + .padding(.horizontal, 12) + }, + trailing: { EmptyView() } + ) +} + +#Preview("Trailing accessory") { + AccessoryTabBarPreviewView( + tabs: [ + .secondary("First"), + .secondary("Second"), + .secondary("Third"), + ], + leading: { EmptyView() }, + trailing: { + Image(systemName: "plus.circle.fill") + .padding(.horizontal, 12) + } + ) +} + +#Preview("Both accessories") { + AccessoryTabBarPreviewView( + tabs: [ + .secondary("First"), + .secondary("Second"), + .secondary("Third"), + ], + leading: { + Image(systemName: "line.3.horizontal.decrease.circle") + .padding(.horizontal, 12) + }, + trailing: { + Image(systemName: "plus.circle.fill") + .padding(.horizontal, 12) + } + ) +} + +#Preview("Accessories, self-sized leading") { + AccessoryTabBarPreviewView( + tabs: [ + .secondary("First"), + .secondary("Second"), + ], + fillAvailableSpace: false, + alignment: .leading, + leading: { + Image(systemName: "line.3.horizontal.decrease.circle") + .padding(.horizontal, 12) + }, + trailing: { + Image(systemName: "plus.circle.fill") + .padding(.horizontal, 12) + } + ) +} diff --git a/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabBar.swift b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabBar.swift index 069a736..f333c07 100644 --- a/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabBar.swift +++ b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabBar.swift @@ -44,6 +44,14 @@ public struct MaterialTabBar: View where Tab: Hashable { case proportionalWidth } + /// Options for horizontal alignment of tab selectors when `fillAvailableSpace` is `false` and tabs don't fill the tab bar width. + /// Has no effect when `fillAvailableSpace` is `true` or when tabs overflow the available width. + public enum Alignment { + case leading + case center + case trailing + } + /// A closure for providing a custom tab selector labels. Custom labels should have greedy width and height /// using `.frame(maxWidth: .infinity, maxHeight: .infinity)`. The tab bar layout will automatically detmerine their intrinsic content sizes /// and set their frames based on the `Sizing` option and available space. All labels will be given the same height, determined by the maximum @@ -61,21 +69,23 @@ public struct MaterialTabBar: View where Tab: Hashable { /// - spacing: The amount of horizontal spacing to use between tab labels. Primary and Secondary tabs should use the default spacing of 0 to /// form a continuous line across the bottom of the tab bar. /// - fillAvailableSpace: Applicable when tab labels don't inherently fill the width of the tab bar. When `true` (the default), the label widths are - /// expanded proportinally to fill the tab bar. When `false`, the labels are not expanded and centered horizontally within the tab bar. + /// expanded proportionally to fill the tab bar. When `false`, the labels are self-sized and positioned according to `alignment`. + /// - alignment: The horizontal alignment of self-sized tabs when `fillAvailableSpace` is `false`. Has no effect when `fillAvailableSpace` is + /// `true` or when tabs overflow the available width. Defaults to `.center`. /// - context: The current context value. public init( selectedTab: Binding, sizing: Sizing = .proportionalWidth, spacing: CGFloat = 0, fillAvailableSpace: Bool = true, + alignment: Alignment = .center, context: MaterialTabsHeaderContext ) { _selectedTab = selectedTab - _selectedTabScroll = State(initialValue: selectedTab.wrappedValue) self.sizing = sizing - self.context = context self.spacing = spacing self.fillAvailableSpace = fillAvailableSpace + self.alignment = alignment } // MARK: - Constants @@ -83,58 +93,21 @@ public struct MaterialTabBar: View where Tab: Hashable { // MARK: - Variables @Binding private var selectedTab: Tab - @State private var selectedTabScroll: Tab? private let sizing: Sizing - @EnvironmentObject private var tabBarModel: TabBarModel - @EnvironmentObject private var headerModel: HeaderModel - @State private var height: CGFloat = 0 - private let context: MaterialTabsHeaderContext private let spacing: CGFloat private let fillAvailableSpace: Bool + private let alignment: Alignment // MARK: - Body public var body: some View { - GeometryReader { proxy in - ScrollView(.horizontal) { - TabBarLayout( - fittingWidth: proxy.size.width, - sizing: sizing, - spacing: spacing, - fillAvailableSpace: fillAvailableSpace - ) { - ForEach(tabBarModel.tabs, id: \.self) { tab in - tabBarModel.labels[tab]?( - tab, - headerModel.state.headerContext, - { - headerModel.selected(tab: tab) - } - ) - .id(tab) - } - } - .scrollTargetLayout() - .frame(minWidth: proxy.size.width) - .background { - GeometryReader { proxy in - Color.clear - .preference(key: TabBarHeightPreferenceKey.self, value: proxy.size.height) - } - } - } - .scrollPosition(id: $selectedTabScroll, anchor: .center) - .scrollIndicators(.never) - .scrollBounceBehavior(.basedOnSize) - .animation(.default, value: selectedTabScroll) - } - .frame(height: height) - .onPreferenceChange(TabBarHeightPreferenceKey.self) { height in - self.height = height - } - .onChange(of: selectedTab) { - selectedTabScroll = selectedTab - } + MaterialTabBarContent( + selectedTab: $selectedTab, + sizing: sizing, + spacing: spacing, + fillAvailableSpace: fillAvailableSpace, + alignment: alignment + ) } } @@ -146,9 +119,16 @@ struct MaterialTabBarPreviewView: View { self.init(tabs: Array(0...Label.secondary("Tab Number \($0)") }, sizing: sizing) } - init(tabs: [MaterialTabBar.Label], sizing: MaterialTabBar.Sizing) { + init( + tabs: [MaterialTabBar.Label], + sizing: MaterialTabBar.Sizing, + fillAvailableSpace: Bool = true, + alignment: MaterialTabBar.Alignment = .center + ) { self.tabs = tabs self.sizing = sizing + self.fillAvailableSpace = fillAvailableSpace + self.alignment = alignment } // MARK: - Constants @@ -157,6 +137,8 @@ struct MaterialTabBarPreviewView: View { private let tabs: [MaterialTabBar.Label] private let sizing: MaterialTabBar.Sizing + private let fillAvailableSpace: Bool + private let alignment: MaterialTabBar.Alignment @State private var selectedTab: Int = 0 // MARK: - Body @@ -165,7 +147,13 @@ struct MaterialTabBarPreviewView: View { MaterialTabs( selectedTab: $selectedTab, headerTabBar: { context in - MaterialTabBar(selectedTab: $selectedTab, sizing: sizing, context: context) + MaterialTabBar( + selectedTab: $selectedTab, + sizing: sizing, + fillAvailableSpace: fillAvailableSpace, + alignment: alignment, + context: context + ) }, content: { ForEach(Array(tabs.enumerated()), id: \.offset) { (offset, tab) in @@ -222,3 +210,39 @@ struct MaterialTabBarPreviewView: View { sizing: .equalWidth ) } + +#Preview("Self-sized, leading") { + MaterialTabBarPreviewView( + tabs: [ + .secondary("First"), + .secondary("Second"), + ], + sizing: .proportionalWidth, + fillAvailableSpace: false, + alignment: .leading + ) +} + +#Preview("Self-sized, trailing") { + MaterialTabBarPreviewView( + tabs: [ + .secondary("First"), + .secondary("Second"), + ], + sizing: .proportionalWidth, + fillAvailableSpace: false, + alignment: .trailing + ) +} + +#Preview("Self-sized, center") { + MaterialTabBarPreviewView( + tabs: [ + .secondary("First"), + .secondary("Second"), + ], + sizing: .proportionalWidth, + fillAvailableSpace: false, + alignment: .center + ) +} diff --git a/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabItemModifier.swift b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabItemModifier.swift index 6563737..3f6ac6e 100644 --- a/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabItemModifier.swift +++ b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabItemModifier.swift @@ -100,7 +100,7 @@ public struct MaterialTabItemModifier: ViewModifier where Tab: Hashable { init(tab: Tab, label: @escaping MaterialTabBar.CustomLabel, tabBarModel: TabBarModel, headerModel: HeaderModel) { tabBarModel.register(tab: tab, label: label) - headerModel.tabsRegistered() + headerModel.onTabsRegistered() } var body: some View { @@ -110,8 +110,8 @@ public struct MaterialTabItemModifier: ViewModifier where Tab: Hashable { // MARK: - Variables - @EnvironmentObject private var headerModel: HeaderModel - @EnvironmentObject private var tabBarModel: TabBarModel + @Environment(HeaderModel.self) private var headerModel + @Environment(TabBarModel.self) private var tabBarModel @State private var foo = 0 // MARK: - Body diff --git a/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabs.swift b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabs.swift index ba21933..37b8a9b 100644 --- a/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabs.swift +++ b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabs.swift @@ -108,17 +108,11 @@ public struct MaterialTabs) -> HeaderView + @ViewBuilder private let headerTitle: (MaterialTabsHeaderContext) -> HeaderTitle + @ViewBuilder private let headerTabBar: (MaterialTabsHeaderContext) -> HeaderTabBar + @ViewBuilder private let headerBackground: (MaterialTabsHeaderContext) -> HeaderBackground @ViewBuilder private let content: () -> Content - @StateObject private var headerModel: HeaderModel - @StateObject private var tabBarModel = TabBarModel() + @State private var headerModel: HeaderModel + @State private var tabBarModel = TabBarModel() // MARK: - Body @@ -144,11 +140,7 @@ public struct MaterialTabs: View { + let headerContext: HeaderContext + @ViewBuilder let headerTitle: (HeaderContext) -> HeaderTitle + @ViewBuilder let headerTabBar: (HeaderContext) -> HeaderTabBar + @ViewBuilder let headerBackground: (HeaderContext) -> HeaderBackground + + var body: some View { + HeaderView( + context: headerContext, + title: headerTitle, + tabBar: headerTabBar, + background: headerBackground + ) + } +} diff --git a/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabsScroll.swift b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabsScroll.swift index 68c6ed4..993955f 100644 --- a/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabsScroll.swift +++ b/Sources/SwiftUIMaterialTabs/MaterialTabs/MaterialTabsScroll.swift @@ -4,117 +4,81 @@ import SwiftUI -/// A lightweight scroll view wrapper that required for sticky header scroll effects. For most intents and purposes, you should use +/// A lightweight scroll view wrapper required for sticky header scroll effects. For most intents and purposes, you should use /// `MaterialTabsScroll` as you would a vertically-oriented `ScrollView`, with typical content being a `VStack` or `LazyVStack`. /// -/// `MaterialTabs` adjusts the scroll position when switching tabs to ensure continuity when switching tabs after collapsing or expanding the header. -/// However, joint maniuplation of scroll position is supported, provided that you supply the scroll position binding. +/// `MaterialTabs` adjusts the scroll position when switching tabs to ensure continuity after collapsing or expanding the header. +/// Joint manipulation of scroll position is supported via the `init(tab:scrollPosition:anchor:content:)` initializer. /// /// Never apply the `scrollPosition()` view modifier to this view because it is already being applied internally. You are free to apply /// `scrollTargetLayout()` to your content as needed. -public struct MaterialTabsScroll: View where Content: View, Tab: Hashable, Item: Hashable { +public struct MaterialTabsScroll: View where Content: View, Tab: Hashable { // MARK: - API - /// Constructs a scroll for the given tab. + /// Constructs a scroll for the given tab with internally-managed scroll position. /// /// - Parameters: /// - tab: The tab that this scroll belongs to. /// - content: The scroll content view builder, typically a `VStack` or `LazyVStack`. /// - /// Never apply the `scrollPosition()` view modifier to this view because it is already being applied internally. You are free to apply - /// `scrollTargetLayout()` to your content as needed. - public init( - tab: Tab, - @ViewBuilder content: @escaping (_ context: MaterialTabsScrollContext) -> Content - ) where Item == ScrollItem { - #if canImport(ScrollPosition) - self.init( - tab: tab, - scrollPosition: scrollPosition - ) - #else - self.tab = tab - self.reservedItem = .item - _scrollItem = .constant(nil) - _scrollUnitPoint = .constant(.top) - _scrollModel = StateObject( - wrappedValue: ScrollModel( - tab: tab, - scrollMode: .scrollAnchor, - reservedItem: .item - ) - ) - self.content = content - #endif - } - - /// Constructs a scroll for the given tab with external bindings for joint manipulation of the scroll position. - /// - /// - Parameters: - /// - tab: The tab that this scroll belongs to. - /// - scrollPosition: The binding to the scroll position. - /// - content: The scroll content view builder, typically a `VStack` or `LazyVStack`. + /// Use this initializer when you don't need programmatic control over scroll position. + /// The library manages scroll position internally for cross-tab header sync. /// - ////// `MaterialTabs` adjusts the scroll position when switching tabs to ensure continuity when switching tabs after collapsing or expanding the header. - /// However, joint maniuplation of scroll position is supported, provided that you supply the scroll position binding. - /// - /// Never apply the `scrollPosition()` view modifier to this view because it is already being applied internally. You are free to apply - /// `scrollTargetLayout()` to your content as needed. - #if canImport(ScrollPosition) + /// Never apply the `scrollPosition()` view modifier to this view because it is already + /// being applied internally. You are free to apply `scrollTargetLayout()` to your content + /// as needed. public init( tab: Tab, - scrollPosition: Binding, @ViewBuilder content: @escaping (_ context: MaterialTabsScrollContext) -> Content ) { self.tab = tab - _scrollPosition = scrollPosition - _scrollModel = StateObject( - wrappedValue: ScrollModel( - tab: tab, - reservedItem: reservedItem - ) - ) + _externalAnchor = .constant(nil) + self.hasExternalScrollPosition = false + _externalScrollPosition = .constant(ScrollPosition()) + _scrollModel = State(wrappedValue: ScrollModel(tab: tab)) self.content = content } - #endif - /// Constructs a scroll for the given tab with external bindings for joint manipulation of the scroll position. + /// Constructs a scroll for the given tab with an external scroll position binding + /// for joint manipulation. /// /// - Parameters: /// - tab: The tab that this scroll belongs to. - /// - reservedItem: A reserved item identifier used internally. - /// - scrollItem: The binding to the scroll item identifier. - /// - scrollUnitPoint: The binding to the scroll unit point. + /// - scrollPosition: A binding to the `ScrollPosition`, enabling programmatic scrolling + /// via `scrollTo(id:anchor:)`, `scrollTo(edge:)`, etc. + /// - anchor: A binding to the anchor point used by the `.scrollPosition()` modifier. + /// When scrolling to a specific item with `scrollTo(id:anchor:)`, the modifier's + /// anchor must match the `scrollTo` anchor for visible items to reposition correctly. + /// Update both values together (see example below). Defaults to `nil`. /// - content: The scroll content view builder, typically a `VStack` or `LazyVStack`. /// - ////// `MaterialTabs` adjusts the scroll position when switching tabs to ensure continuity when switching tabs after collapsing or expanding the header. - /// However, joint maniuplation of scroll position is supported, provided that you supply the scroll item and unit point bindings. However, when - /// using joint manipulation, you must supply a `reservedItem` identifier for `MaterialTabs` to use internally on its own hidden view. This approach was - /// adopted because precise manipulation of scroll position requires knowing the height the view associated with the scroll item and using our own internal - /// view for that seemed the easiest solution. + /// The library manages cross-tab header sync automatically. Joint manipulation allows + /// additional programmatic scrolling on top of that. + /// + /// When scrolling to a specific anchor, update both the anchor binding and the + /// `scrollTo` call together: + /// ```swift + /// withAnimation { + /// scrollAnchor = .top + /// scrollPosition.scrollTo(id: itemID, anchor: .top) + /// } + /// ``` /// - /// Never apply the `scrollPosition()` view modifier to this view because it is already being applied internally. You are free to apply - /// `scrollTargetLayout()` to your content as needed. - @available(*, deprecated, message: "Only use this with apps that need to support versions of iOS less than 18. In iOS 18, the `reservedItem` is not needed.") + /// Never apply the `scrollPosition()` view modifier to this view because it is already + /// being applied internally. You are free to apply `scrollTargetLayout()` to your content + /// as needed. public init( tab: Tab, - reservedItem: Item, - scrollItem: Binding, - scrollUnitPoint: Binding, + scrollPosition: Binding, + anchor: Binding = .constant(nil), @ViewBuilder content: @escaping (_ context: MaterialTabsScrollContext) -> Content ) { self.tab = tab - self.reservedItem = reservedItem - _scrollItem = scrollItem - _scrollUnitPoint = scrollUnitPoint - _scrollModel = StateObject( - wrappedValue: ScrollModel( - tab: tab, - scrollMode: .scrollAnchor, - reservedItem: reservedItem - ) - ) + _externalAnchor = anchor + self.hasExternalScrollPosition = true + _externalScrollPosition = scrollPosition + _scrollModel = State(wrappedValue: ScrollModel(tab: tab)) self.content = content } @@ -123,16 +87,25 @@ public struct MaterialTabsScroll: View where Content: View, // MARK: - Variables private let tab: Tab - private let reservedItem: Item? + @Binding private var externalAnchor: UnitPoint? + @State private var internalAnchor: UnitPoint? + private let hasExternalScrollPosition: Bool @State private var coordinateSpaceName = UUID() - #if canImport(ScrollPosition) - @Binding private var scrollPosition = ScrollPosition(idType: Item.self) - #endif - @Binding private var scrollItem: Item? - @Binding private var scrollUnitPoint: UnitPoint - @StateObject private var scrollModel: ScrollModel + @State private var internalScrollPosition = ScrollPosition() + @Binding private var externalScrollPosition: ScrollPosition + @State private var scrollModel: ScrollModel @ViewBuilder private var content: (_ context: MaterialTabsScrollContext) -> Content - @EnvironmentObject private var headerModel: HeaderModel + @Environment(HeaderModel.self) private var headerModel + + /// The active scroll position binding — either the consumer's external binding or our internal @State. + private var activeScrollPosition: Binding { + hasExternalScrollPosition ? $externalScrollPosition : $internalScrollPosition + } + + /// The active anchor binding — either the consumer's external binding or our internal @State. + private var activeAnchor: Binding { + hasExternalScrollPosition ? $externalAnchor : $internalAnchor + } // MARK: - Body @@ -140,7 +113,7 @@ public struct MaterialTabsScroll: View where Content: View, ScrollView { VStack(spacing: 0) { Color.clear - .frame(height: headerModel.state.headerContext.maxOffset) + .frame(height: headerModel.headerContext.maxOffset) .background { GeometryReader { proxy in Color.clear.preference( @@ -153,14 +126,30 @@ public struct MaterialTabsScroll: View where Content: View, scrollModel.contentOffsetChanged(offset) } ZStack(alignment: .top) { - Color.clear - .frame(height: 1) - .id(reservedItem) - content( - MaterialTabsScrollContext( - headerContext: headerModel.state.headerContext, - safeHeight: headerModel.state.safeHeight - ) + // Reserved item: a hidden 1pt view used as the scroll target for + // programmatic offset adjustments via scrollTo(id:anchor:). + // + // Why not scrollTo(y:)? + // scrollTo(y:) causes position oscillation in lazy stacks when the + // content contains dynamically-sized views (views without a fixed + // frame height). When the scroll view jumps to a y offset, the lazy + // stack may unload/reload dynamically-sized views and re-estimate + // their heights, producing a content offset that differs from the + // requested y by exactly the size estimation error. The preference + // key then reports this shifted offset, the scroll view corrects, + // and the cycle repeats — sometimes settling correctly (even number + // of bounces), sometimes not (odd bounces). + // + // scrollTo(id:anchor:) targeting a fixed-size view avoids this + // because the scroll view can always resolve the 1pt item's position + // without lazy estimation. The UnitPoint anchor is calculated to + // achieve the desired content offset (see ScrollModel). + Color.clear.frame(height: 1) + .id(scrollModel.reservedItemID) + ContentBridgeView( + headerContext: headerModel.headerContext, + safeHeight: headerModel.safeHeight, + content: content ) .background { GeometryReader(content: { proxy in @@ -172,66 +161,27 @@ public struct MaterialTabsScroll: View where Content: View, } } .coordinateSpace(name: coordinateSpaceName) - .map { content in - switch scrollModel.scrollMode { - case .scrollAnchor: - content - .scrollPosition(id: $scrollModel.scrollItem, anchor: scrollModel.scrollUnitPoint) - case .scrollPosition: - #if canImport(ScrollPosition) - content - .scrollPosition(scrollPosition) - #else - content - #endif - } - } - .transaction(value: scrollModel.scrollItem) { transation in - // Sometimes this happens in an animation context, but this prevents animation - transation.animation = nil - } + .scrollPosition(activeScrollPosition, anchor: activeAnchor.wrappedValue) .onPreferenceChange(ScrollViewContentSizeKey.self) { size in guard let size else { return } scrollModel.contentSizeChanged(size) } .onAppear { - switch scrollModel.scrollMode { - case .scrollAnchor: - // It is important not to attempt to adjust the scroll position until after the view has appeared - // and this task seems to accomplish that. - Task { - scrollModel.appeared(headerModel: headerModel) - } - case .scrollPosition: - scrollModel.appeared(headerModel: headerModel) - } + scrollModel.appeared(headerModel: headerModel, scrollPositionBinding: activeScrollPosition, anchorBinding: activeAnchor) } - .onChange(of: headerModel.state.headerContext.selectedTab, initial: true) { + .onChange(of: headerModel.headerContext.selectedTab, initial: true) { scrollModel.selectedTabChanged() } - .onChange(of: scrollItem, initial: true) { - scrollModel.scrollItemChanged(scrollItem) - } - .onChange(of: scrollUnitPoint, initial: true) { - scrollModel.scrollUnitPointChanged(scrollUnitPoint) - } - .map { content in - #if canImport(ScrollPosition) - content - .onChange(of: scrollPosition, intial: true) { - scrollModel.scrollPositionChanged(scrollPosition) - } - #else - content - #endif - } - .onChange(of: headerModel.state.headerContext.height) { + .onChange(of: headerModel.headerContext.height) { scrollModel.headerHeightChanged() } - .onChange(of: headerModel.state.headerContext.safeArea.top) { + .onChange(of: headerModel.headerContext.safeArea.top) { scrollModel.headerHeightChanged() } - .onChange(of: headerModel.state) { + .onChange(of: headerModel.height) { + scrollModel.headerStateChanged() + } + .onChange(of: headerModel.headerContext.minTotalHeight) { scrollModel.headerStateChanged() } .onDisappear() { @@ -240,9 +190,28 @@ public struct MaterialTabsScroll: View where Content: View, } } +/// Bridge view that invokes the content closure in its own body scope. +/// This isolates @Observable property tracking — reads of scroll-related +/// properties (like offset) inside the content closure are tracked here, +/// not in MaterialTabsScroll.body. +private struct ContentBridgeView: View { + let headerContext: HeaderContext + let safeHeight: CGFloat + @ViewBuilder let content: (_ context: MaterialTabsScrollContext) -> Content + + var body: some View { + content( + MaterialTabsScrollContext( + headerContext: headerContext, + safeHeight: safeHeight + ) + ) + } +} + private struct ScrollViewContentSizeKey: PreferenceKey { typealias Value = CGSize? - static var defaultValue: CGSize? = nil + static let defaultValue: CGSize? = nil public static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) { guard let next = nextValue() else { return } value = next diff --git a/Sources/SwiftUIMaterialTabs/StickyHeader/StickyHeader.swift b/Sources/SwiftUIMaterialTabs/StickyHeader/StickyHeader.swift index 8009175..b89c32b 100644 --- a/Sources/SwiftUIMaterialTabs/StickyHeader/StickyHeader.swift +++ b/Sources/SwiftUIMaterialTabs/StickyHeader/StickyHeader.swift @@ -58,25 +58,20 @@ public struct StickyHeader: View @ViewBuilder headerBackground: @escaping (StickyHeaderContext) -> HeaderBackground, @ViewBuilder content: @escaping () -> Content ) { - self.header = { context in - HeaderView( - context: context, - title: headerTitle, - tabBar: { _ in EmptyView() }, - background: headerBackground - ) - } + self.headerTitle = headerTitle + self.headerBackground = headerBackground self.content = content - _headerModel = StateObject(wrappedValue: HeaderModel(selectedTab: .none)) + _headerModel = State(wrappedValue: HeaderModel(selectedTab: .none)) } // MARK: - Constants // MARK: - Variables - @ViewBuilder private let header: (StickyHeaderContext) -> HeaderView + @ViewBuilder private let headerTitle: (StickyHeaderContext) -> HeaderTitle + @ViewBuilder private let headerBackground: (StickyHeaderContext) -> HeaderBackground @ViewBuilder private let content: () -> Content - @StateObject private var headerModel: HeaderModel + @State private var headerModel: HeaderModel // MARK: - Body @@ -88,21 +83,43 @@ public struct StickyHeader: View // calculations work out better. For example, scrolling an item to `.top` // results in a fully collapsed header with the item touching the header // as one would expect. - .safeAreaPadding(.top, headerModel.state.headerContext.minTotalHeight) + .safeAreaPadding(.top, headerModel.headerContext.minTotalHeight) .onChange(of: proxy.size.height, initial: true) { headerModel.sizeChanged(proxy.size) } - header(headerModel.state.headerContext) + // Bridge view — header closures invoked in their own scope + StickyHeaderBridgeView( + headerContext: headerModel.headerContext, + headerTitle: headerTitle, + headerBackground: headerBackground + ) } .onChange(of: proxy.safeAreaInsets, initial: true) { headerModel.safeAreaChanged(proxy.safeAreaInsets) } } - .environmentObject(headerModel) + .environment(headerModel) + .environment(headerModel.headerContext) .onPreferenceChange(TitleHeightPreferenceKey.self, perform: headerModel.titleHeightChanged(_:)) .onPreferenceChange(MinTitleHeightPreferenceKey.self, perform: headerModel.minTitleHeightChanged(_:)) .onAppear { - headerModel.tabsRegistered() + headerModel.onTabsRegistered() } } } + +/// Bridge view for StickyHeader — invokes header closures in its own body scope. +private struct StickyHeaderBridgeView: View { + let headerContext: HeaderContext + @ViewBuilder let headerTitle: (HeaderContext) -> HeaderTitle + @ViewBuilder let headerBackground: (HeaderContext) -> HeaderBackground + + var body: some View { + HeaderView( + context: headerContext, + title: headerTitle, + tabBar: { _ in EmptyView() }, + background: headerBackground + ) + } +} diff --git a/Sources/SwiftUIMaterialTabs/StickyHeader/StickyHeaderScroll.swift b/Sources/SwiftUIMaterialTabs/StickyHeader/StickyHeaderScroll.swift index fb76a17..3f17403 100644 --- a/Sources/SwiftUIMaterialTabs/StickyHeader/StickyHeaderScroll.swift +++ b/Sources/SwiftUIMaterialTabs/StickyHeader/StickyHeaderScroll.swift @@ -7,7 +7,7 @@ import SwiftUI /// A lightweight scroll view wrapper required for sticky header scroll effects. For most intents and purposes, you should use `StickyHeaderScroll` as you /// would a vertically-oriented `ScrollView`, with typical content being a `VStack` or `LazyVStack`. The main task that `StickyHeaderScroll` /// performs is to track the content offset. -public struct StickyHeaderScroll: View where Content: View, Item: Hashable { +public struct StickyHeaderScroll: View where Content: View { // MARK: - API @@ -17,7 +17,7 @@ public struct StickyHeaderScroll: View where Content: View, Item: /// - content: The scroll content view builder, typically a `VStack` or `LazyVStack`. public init( @ViewBuilder content: @escaping (_ context: StickyHeaderScrollContext) -> Content - ) where Item == ScrollItem { + ) { self.content = content } @@ -41,7 +41,7 @@ public struct StickyHeaderScroll: View where Content: View, Item: @State private var coordinateSpaceName = UUID() @State private var contentOffset: CGFloat = 0 @ViewBuilder private var content: (_ context: StickyHeaderScrollContext) -> Content - @EnvironmentObject private var headerModel: HeaderModel + @Environment(HeaderModel.self) private var headerModel // MARK: - Body @@ -49,7 +49,7 @@ public struct StickyHeaderScroll: View where Content: View, Item: ScrollView { VStack(spacing: 0) { Color.clear - .frame(height: headerModel.state.headerContext.maxOffset) + .frame(height: headerModel.headerContext.maxOffset) .background { GeometryReader { proxy in Color.clear.preference( @@ -66,14 +66,30 @@ public struct StickyHeaderScroll: View where Content: View, Item: ) contentOffset = offset } - content( - StickyHeaderScrollContext( - headerContext: headerModel.state.headerContext, - safeHeight: headerModel.state.safeHeight - ) + // Bridge view — content closure invoked in its own scope + StickyHeaderContentBridgeView( + headerContext: headerModel.headerContext, + safeHeight: headerModel.safeHeight, + content: content ) } } .coordinateSpace(name: coordinateSpaceName) } } + +/// Bridge view for StickyHeaderScroll content — isolates @Observable tracking. +private struct StickyHeaderContentBridgeView: View { + let headerContext: HeaderContext + let safeHeight: CGFloat + @ViewBuilder let content: (_ context: StickyHeaderScrollContext) -> Content + + var body: some View { + content( + StickyHeaderScrollContext( + headerContext: headerContext, + safeHeight: safeHeight + ) + ) + } +}