From d7e75a5c01a4decdbc2e642b3c5761359e41c630 Mon Sep 17 00:00:00 2001 From: Yusuke Morishita Date: Fri, 10 Jul 2026 07:51:39 -0700 Subject: [PATCH 1/2] Add a SwiftUI SwipeMenu component SwipeMenu (iOS 18+) presents the same scrollable tab bar above a horizontally paging content area as SwipeMenuView, with a SwiftUI-native API: the selected page is a Binding, pages come from a view builder, and appearance is configured with the new SwipeMenuOptions. The onWillChangeIndex/onDidChangeIndex closures mirror the delegate paging callbacks and fire exactly once per move. The selection indicator and tab title colors are interpolated from the content's continuous scroll progress, so they track the finger during swipes, and the flexible tab bar scrolls to keep the indicator in view. The interpolation math lives in SwipeMenuGeometry, a pure nonisolated helper covered by unit tests, alongside defaults tests for SwipeMenuOptions. The example app gains a SwiftUI demo (menu, options form, and settings mapping, all sharing the existing SwipeMenuSettings model) reachable from a new floating button, the DocC catalog gains a "SwipeMenu in SwiftUI" article, and the READMEs and changelog document the SwiftUI support. --- CHANGELOG.md | 1 + Example/Example/MenuViewController.swift | 34 ++- Example/Example/SwiftUIMenuView.swift | 76 ++++++ Example/Example/SwiftUIOptionsView.swift | 90 +++++++ .../Example/SwipeMenuSettings+SwiftUI.swift | 43 ++++ .../SwipeMenuSettingsSwiftUITests.swift | 68 +++++ Example/README.md | 13 +- README.md | 32 ++- .../SwiftUI/SwipeMenu.swift | 162 ++++++++++++ .../SwiftUI/SwipeMenuGeometry.swift | 101 ++++++++ .../SwiftUI/SwipeMenuOptions.swift | 154 ++++++++++++ .../SwiftUI/SwipeTabBar.swift | 238 ++++++++++++++++++ .../SwipeMenuInSwiftUI.md | 110 ++++++++ .../SwipeMenuViewController.md | 9 +- .../SwipeMenuGeometryTests.swift | 170 +++++++++++++ .../SwipeMenuOptionsTests.swift | 81 ++++++ 16 files changed, 1373 insertions(+), 9 deletions(-) create mode 100644 Example/Example/SwiftUIMenuView.swift create mode 100644 Example/Example/SwiftUIOptionsView.swift create mode 100644 Example/Example/SwipeMenuSettings+SwiftUI.swift create mode 100644 Example/ExampleTests/SwipeMenuSettingsSwiftUITests.swift create mode 100644 Sources/SwipeMenuViewController/SwiftUI/SwipeMenu.swift create mode 100644 Sources/SwipeMenuViewController/SwiftUI/SwipeMenuGeometry.swift create mode 100644 Sources/SwipeMenuViewController/SwiftUI/SwipeMenuOptions.swift create mode 100644 Sources/SwipeMenuViewController/SwiftUI/SwipeTabBar.swift create mode 100644 Sources/SwipeMenuViewController/SwipeMenuViewController.docc/SwipeMenuInSwiftUI.md create mode 100644 Tests/SwipeMenuViewControllerTests/SwipeMenuGeometryTests.swift create mode 100644 Tests/SwipeMenuViewControllerTests/SwipeMenuOptionsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 83afae7..c24bf03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - `SwipeMenuViewOptions.TabView.ItemView.selectedFont` to use a different title font while a tab is selected. Defaults to the same 14 pt bold system font as `font`, so the title font does not change on selection unless you set it. It affects the selected title's appearance only; in the `.flexible` style item widths are still measured with `font`. - `SwipeMenuViewOptions.TabView.ItemView.numberOfLines` to let tab titles wrap onto multiple lines (use `0` for as many lines as the title needs). Defaults to `1`, preserving the previous single-line behavior. Most useful with the `.segmented` style, where a long title would otherwise be truncated. - `SwipeMenuViewOptions.TabView.IndicatorView.Underline.cornerRadius` to round the corners of the underline indicator (set it to half the underline height for a pill shape). Defaults to `0`, preserving the previous square corners. +- SwiftUI support (iOS 18+): a `SwipeMenu` view that presents the same tab bar and paging UI driven by a selection binding, configured with the SwiftUI-native `SwipeMenuOptions`, with `onWillChangeIndex`/`onDidChangeIndex` closures mirroring the delegate callbacks. The selection indicator and tab title colors track the swipe continuously, and the `.flexible` tab bar keeps the selection in view, matching the UIKit behavior. Covered by a new "SwipeMenu in SwiftUI" DocC article and a SwiftUI demo in the example app. ### Fixed - The `.segmented` tab style mispositioned the selection indicator when `indicatorView.padding` had non-zero horizontal insets: the first tab's indicator spilled off the leading edge, and each later tab drifted increasingly to the left. The indicator now aligns with every tab, inset by the padding, consistent with the other tab styles (issue #25). diff --git a/Example/Example/MenuViewController.swift b/Example/Example/MenuViewController.swift index 5c5f79c..ad0e69a 100644 --- a/Example/Example/MenuViewController.swift +++ b/Example/Example/MenuViewController.swift @@ -1,3 +1,4 @@ +import SwiftUI import SwipeMenuViewController import UIKit @@ -6,6 +7,8 @@ import UIKit /// A ``SwipeMenuViewController`` whose pages come from a fixed list of names, with a /// floating button that presents the live ``OptionsViewController``. Changing an /// option rebuilds the menu through `SwipeMenuView.reloadData(options:default:)`. +/// A second floating button presents ``SwiftUIMenuView``, the same demo built on +/// the SwiftUI ``SwipeMenu``. final class MenuViewController: SwipeMenuViewController { private let pageTitles = [ @@ -32,6 +35,21 @@ final class MenuViewController: SwipeMenuViewController { return button }() + private lazy var swiftUIButton: UIButton = { + var configuration = UIButton.Configuration.glass() + configuration.image = UIImage(systemName: "swift") + configuration.cornerStyle = .capsule + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .medium) + let button = UIButton( + configuration: configuration, + primaryAction: UIAction { [weak self] _ in + self?.presentSwiftUIExample() + }) + button.accessibilityLabel = "SwiftUI Example" + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + private static let settingsButtonDiameter: CGFloat = 56 override func viewDidLoad() { @@ -49,7 +67,7 @@ final class MenuViewController: SwipeMenuViewController { // The container sets the menu up with default options; apply ours so the // initial appearance matches `settings` (including dark-mode colors). swipeMenuView.reloadData(options: settings.makeOptions()) - setUpSettingsButton() + setUpFloatingButtons() } // MARK: - SwipeMenuViewDataSource @@ -60,13 +78,19 @@ final class MenuViewController: SwipeMenuViewController { // MARK: - Options - private func setUpSettingsButton() { + private func setUpFloatingButtons() { view.addSubview(settingsButton) + view.addSubview(swiftUIButton) NSLayoutConstraint.activate([ settingsButton.widthAnchor.constraint(equalToConstant: Self.settingsButtonDiameter), settingsButton.heightAnchor.constraint(equalToConstant: Self.settingsButtonDiameter), settingsButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16), settingsButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), + + swiftUIButton.widthAnchor.constraint(equalToConstant: Self.settingsButtonDiameter), + swiftUIButton.heightAnchor.constraint(equalToConstant: Self.settingsButtonDiameter), + swiftUIButton.centerXAnchor.constraint(equalTo: settingsButton.centerXAnchor), + swiftUIButton.bottomAnchor.constraint(equalTo: settingsButton.topAnchor, constant: -12), ]) } @@ -84,6 +108,12 @@ final class MenuViewController: SwipeMenuViewController { present(navigationController, animated: true) } + private func presentSwiftUIExample() { + let hostingController = UIHostingController(rootView: SwiftUIMenuView()) + hostingController.modalPresentationStyle = .fullScreen + present(hostingController, animated: true) + } + private func apply(_ settings: SwipeMenuSettings) { self.settings = settings // Keep the visible page in range if the page count shrank. diff --git a/Example/Example/SwiftUIMenuView.swift b/Example/Example/SwiftUIMenuView.swift new file mode 100644 index 0000000..cd42f8d --- /dev/null +++ b/Example/Example/SwiftUIMenuView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import SwipeMenuViewController + +/// The SwiftUI counterpart of ``MenuViewController``: the same demo pages shown +/// through the SwiftUI ``SwipeMenu``, with a floating button that presents +/// ``SwiftUIOptionsView``. Both demos edit the same ``SwipeMenuSettings`` model; +/// here every change re-renders the menu declaratively instead of calling +/// `reloadData(options:default:)`. +struct SwiftUIMenuView: View { + + @Environment(\.dismiss) private var dismiss + + @State private var settings = SwipeMenuSettings() + @State private var selection = 0 + @State private var isPresentingOptions = false + + private static let pageTitles = [ + "Bulbasaur", "Caterpie", "Golem", "Jynx", + "Marshtomp", "Salamence", "Riolu", "Araquanid", + ] + + private var titles: [String] { + Array(Self.pageTitles.prefix(settings.pageCount)) + } + + var body: some View { + let titles = self.titles + SwipeMenu(selection: $selection, titles: titles, options: settings.makeSwiftUIOptions()) { index in + Text(titles[index]) + .font(.largeTitle) + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color(.systemBackground).ignoresSafeArea()) + .overlay(alignment: .bottomTrailing) { floatingButtons } + .onChange(of: settings.pageCount) { _, newCount in + // Keep the visible page in range if the page count shrank. + selection = min(max(selection, 0), newCount - 1) + } + .sheet(isPresented: $isPresentingOptions) { + SwiftUIOptionsView(settings: $settings) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } + + private var floatingButtons: some View { + VStack(spacing: 12) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 22, weight: .medium)) + .frame(width: 32, height: 32) + } + .buttonStyle(.glass) + .accessibilityLabel("Close") + + Button { + isPresentingOptions = true + } label: { + Image(systemName: "gearshape") + .font(.system(size: 22, weight: .medium)) + .frame(width: 32, height: 32) + } + .buttonStyle(.glassProminent) + .accessibilityLabel("Options") + } + .padding(16) + } +} + +#Preview { + SwiftUIMenuView() +} diff --git a/Example/Example/SwiftUIOptionsView.swift b/Example/Example/SwiftUIOptionsView.swift new file mode 100644 index 0000000..3571719 --- /dev/null +++ b/Example/Example/SwiftUIOptionsView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// A form that edits a ``SwipeMenuSettings`` through a binding, so the menu behind +/// the sheet updates live — the SwiftUI counterpart of ``OptionsViewController``. +/// +/// The rows mirror the UIKit form: the tab-width controls appear only while the +/// flexible style is active (the segmented style always fills the width), and +/// **Reset** restores the defaults. +struct SwiftUIOptionsView: View { + + @Binding var settings: SwipeMenuSettings + + @Environment(\.dismiss) private var dismiss + + /// Routes style changes through ``SwipeMenuSettings/setStyle(_:)`` so the page + /// count is re-clamped to the new style's limit. + private var style: Binding { + Binding( + get: { settings.style }, + set: { settings.setStyle($0) }) + } + + var body: some View { + NavigationStack { + Form { + Section { + Stepper(value: $settings.pageCount, in: SwipeMenuSettings.minimumPageCount...settings.maximumPageCount) { + LabeledContent("Pages", value: "\(settings.pageCount)") + } + } + + Section { + Picker("Style", selection: style) { + Text("Flexible").tag(SwipeMenuSettings.Style.flexible) + Text("Segmented").tag(SwipeMenuSettings.Style.segmented) + } + .pickerStyle(.segmented) + + Picker("Tab decoration", selection: $settings.tabDecoration) { + Text("Underline").tag(SwipeMenuSettings.TabDecoration.underline) + Text("Circle").tag(SwipeMenuSettings.TabDecoration.circle) + Text("None").tag(SwipeMenuSettings.TabDecoration.none) + } + .pickerStyle(.segmented) + } header: { + Text("Tab bar") + } + + Section { + if settings.style == .flexible { + Toggle("Adjust tab width to fit", isOn: $settings.adjustsItemWidthToFit) + + if !settings.adjustsItemWidthToFit { + LabeledContent("Tab width", value: Self.format(settings.itemWidth)) + Slider(value: $settings.itemWidth, in: 80...300) + } + } + + LabeledContent("Tab margin", value: Self.format(settings.tabMargin)) + Slider(value: $settings.tabMargin, in: 0...20) + } header: { + Text("Layout") + } + + Section { + Toggle("Swipe between pages", isOn: $settings.isContentScrollEnabled) + } + } + .navigationTitle("Options") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Reset") { settings = SwipeMenuSettings() } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + private static func format(_ value: CGFloat) -> String { + String(format: "%.0f", Double(value)) + } +} + +#Preview { + @Previewable @State var settings = SwipeMenuSettings() + SwiftUIOptionsView(settings: $settings) +} diff --git a/Example/Example/SwipeMenuSettings+SwiftUI.swift b/Example/Example/SwipeMenuSettings+SwiftUI.swift new file mode 100644 index 0000000..38c7365 --- /dev/null +++ b/Example/Example/SwipeMenuSettings+SwiftUI.swift @@ -0,0 +1,43 @@ +import SwiftUI +import SwipeMenuViewController + +extension SwipeMenuSettings { + + /// Builds the ``SwipeMenuOptions`` for the SwiftUI ``SwipeMenu`` described by + /// these settings — the SwiftUI counterpart of ``makeOptions()``. + /// + /// Colors are resolved from semantic `UIColor`s so the menu adapts to light + /// and dark appearances. + /// - Returns: Options ready to hand to a ``SwipeMenu``. + func makeSwiftUIOptions() -> SwipeMenuOptions { + var options = SwipeMenuOptions() + + options.tabView.margin = tabMargin + options.tabView.adjustsItemViewWidth = adjustsItemWidthToFit + options.tabView.itemView.width = itemWidth + options.tabView.itemView.textColor = Color(.secondaryLabel) + options.tabView.indicatorView.backgroundColor = Color(.label) + + switch style { + case .flexible: options.tabView.style = .flexible + case .segmented: options.tabView.style = .segmented + } + + switch tabDecoration { + case .underline: + options.tabView.indicator = .underline + options.tabView.itemView.selectedTextColor = Color(.label) + case .circle: + options.tabView.indicator = .circle + // The pill is filled with `.label`, so the title inverts to stay legible. + options.tabView.itemView.selectedTextColor = Color(.systemBackground) + case .none: + options.tabView.indicator = .none + options.tabView.itemView.selectedTextColor = Color(.label) + } + + options.contentScrollView.isScrollEnabled = isContentScrollEnabled + + return options + } +} diff --git a/Example/ExampleTests/SwipeMenuSettingsSwiftUITests.swift b/Example/ExampleTests/SwipeMenuSettingsSwiftUITests.swift new file mode 100644 index 0000000..8dab71c --- /dev/null +++ b/Example/ExampleTests/SwipeMenuSettingsSwiftUITests.swift @@ -0,0 +1,68 @@ +import SwiftUI +import SwipeMenuViewController +import Testing +import UIKit + +@testable import Example + +@Suite("SwipeMenuSettings+SwiftUI") +struct SwipeMenuSettingsSwiftUITests { + + @Test("Default settings map to the expected SwiftUI options") + func defaultsMapToOptions() { + let options = SwipeMenuSettings().makeSwiftUIOptions() + + #expect(options.tabView.style == .flexible) + #expect(options.tabView.indicator == .underline) + #expect(options.tabView.margin == 0) + #expect(options.tabView.adjustsItemViewWidth) + #expect(options.tabView.itemView.width == 100) + #expect(options.tabView.itemView.textColor == Color(.secondaryLabel)) + #expect(options.tabView.indicatorView.backgroundColor == Color(.label)) + #expect(options.contentScrollView.isScrollEnabled) + } + + @Test("The style is forwarded to the SwiftUI options") + func styleIsForwarded() { + var settings = SwipeMenuSettings() + settings.setStyle(.segmented) + + #expect(settings.makeSwiftUIOptions().tabView.style == .segmented) + } + + @Test("Each decoration maps to its indicator and a legible selected color") + func decorationMapsToIndicatorAndColor() { + var settings = SwipeMenuSettings() + + settings.tabDecoration = .underline + var options = settings.makeSwiftUIOptions() + #expect(options.tabView.indicator == .underline) + #expect(options.tabView.itemView.selectedTextColor == Color(.label)) + + settings.tabDecoration = .circle + options = settings.makeSwiftUIOptions() + #expect(options.tabView.indicator == .circle) + #expect(options.tabView.itemView.selectedTextColor == Color(.systemBackground)) + + settings.tabDecoration = .none + options = settings.makeSwiftUIOptions() + #expect(options.tabView.indicator == .none) + #expect(options.tabView.itemView.selectedTextColor == Color(.label)) + } + + @Test("Continuous and boolean settings are forwarded to the SwiftUI options") + func numericAndBooleanSettingsAreForwarded() { + var settings = SwipeMenuSettings() + settings.tabMargin = 12 + settings.itemWidth = 220 + settings.adjustsItemWidthToFit = false + settings.isContentScrollEnabled = false + + let options = settings.makeSwiftUIOptions() + + #expect(options.tabView.margin == 12) + #expect(options.tabView.itemView.width == 220) + #expect(!options.tabView.adjustsItemViewWidth) + #expect(!options.contentScrollView.isScrollEnabled) + } +} diff --git a/Example/README.md b/Example/README.md index e0e9ca3..f228ab8 100644 --- a/Example/README.md +++ b/Example/README.md @@ -2,10 +2,15 @@ A small iOS app that demonstrates `SwipeMenuViewController` and lets you tweak `SwipeMenuViewOptions` live. It is written entirely in code — no storyboards — -with a `UIWindowScene` lifecycle, SF Symbols, and a Liquid Glass options button, -and the option-building logic in -[`SwipeMenuSettings`](./Example/SwipeMenuSettings.swift) is covered by unit tests -in [`ExampleTests`](./ExampleTests). +with a `UIWindowScene` lifecycle, SF Symbols, and Liquid Glass floating buttons. +The Swift-bird button opens the same demo rebuilt on the SwiftUI `SwipeMenu` +([`SwiftUIMenuView`](./Example/SwiftUIMenuView.swift)), whose options are edited +in a SwiftUI form ([`SwiftUIOptionsView`](./Example/SwiftUIOptionsView.swift)). + +Both demos share one settings model: the option-building logic in +[`SwipeMenuSettings`](./Example/SwipeMenuSettings.swift) and its SwiftUI mapping +in [`SwipeMenuSettings+SwiftUI`](./Example/SwipeMenuSettings+SwiftUI.swift) are +covered by unit tests in [`ExampleTests`](./ExampleTests). The Xcode project is generated from [`project.yml`](./project.yml) with [XcodeGen](https://github.com/yonaskolb/XcodeGen), so `Example.xcodeproj` is not diff --git a/README.md b/README.md index 1c5bd9e..0b92b9e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Documentation](https://img.shields.io/badge/documentation-DocC-blueviolet.svg?style=for-the-badge)](https://yysskk.github.io/SwipeMenuViewController/documentation/swipemenuviewcontroller/) [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) -Swipe-based paging UI for iOS: a scrollable tab bar sits above a horizontally paging content area, and swiping the content keeps the tab selection in sync. You populate it through data source and delegate protocols modeled on UIKit's own, and tune its appearance with `SwipeMenuViewOptions`. +Swipe-based paging UI for iOS: a scrollable tab bar sits above a horizontally paging content area, and swiping the content keeps the tab selection in sync. Use it from UIKit through data source and delegate protocols, or from SwiftUI (iOS 18+) as `SwipeMenu` with a selection binding. | Segmented Tab & Underline | Flexible Tab & Underline | Flexible Tab & Circle | |:---:|:---:|:---:| @@ -15,7 +15,7 @@ Swipe-based paging UI for iOS: a scrollable tab bar sits above a horizontally pa ## Requirements -- iOS 16.0+ +- iOS 16.0+ (the SwiftUI `SwipeMenu` requires iOS 18.0+) - Xcode 26.0+ / Swift 6.2+ > Need an older toolchain or deployment target? Use the [4.x releases](https://github.com/yysskk/SwipeMenuViewController/releases). @@ -32,6 +32,8 @@ dependencies: [ ## Quick Start +### UIKit + Subclass `SwipeMenuViewController` and add your pages as child view controllers. Each child becomes a page: its `title` is the tab title and its view is the page content. ```swift @@ -56,6 +58,31 @@ final class MenuViewController: SwipeMenuViewController { To embed the paging UI in a view hierarchy you already have, add a `SwipeMenuView` directly and drive it through `SwipeMenuViewDataSource` — the [Getting Started](https://yysskk.github.io/SwipeMenuViewController/documentation/swipemenuviewcontroller/gettingstarted) article walks through both approaches. +### SwiftUI + +On iOS 18 and later, use `SwipeMenu` to drive the same UI from SwiftUI. The selected page is a binding — setting it is the equivalent of `jump(to:animated:)` — pages come from a view builder, and the appearance is configured with `SwipeMenuOptions`: + +```swift +import SwiftUI +import SwipeMenuViewController + +struct ContentView: View { + + @State private var selection = 0 + + private let titles = ["First", "Second"] + + var body: some View { + SwipeMenu(selection: $selection, titles: titles) { index in + Text(titles[index]) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} +``` + +The [SwipeMenu in SwiftUI](https://yysskk.github.io/SwipeMenuViewController/documentation/swipemenuviewcontroller/swipemenuinswiftui) article covers the options, the paging callbacks, and the differences from the UIKit view. + ## Documentation The full API reference and guides are published with DocC: @@ -64,6 +91,7 @@ The full API reference and guides are published with DocC: - [Getting Started](https://yysskk.github.io/SwipeMenuViewController/documentation/swipemenuviewcontroller/gettingstarted) — set up `SwipeMenuView` or `SwipeMenuViewController` - [Customizing Appearance](https://yysskk.github.io/SwipeMenuViewController/documentation/swipemenuviewcontroller/customizingappearance) — every `SwipeMenuViewOptions` field +- [SwipeMenu in SwiftUI](https://yysskk.github.io/SwipeMenuViewController/documentation/swipemenuviewcontroller/swipemenuinswiftui) — drive the same UI from SwiftUI with a selection binding You can also build the documentation locally in Xcode with **Product ▸ Build Documentation**, or explore the [Example app](./Example) in this repository (its Xcode project is generated with [XcodeGen](https://github.com/yonaskolb/XcodeGen) — see the [Example README](./Example/README.md)). diff --git a/Sources/SwipeMenuViewController/SwiftUI/SwipeMenu.swift b/Sources/SwipeMenuViewController/SwiftUI/SwipeMenu.swift new file mode 100644 index 0000000..abc7656 --- /dev/null +++ b/Sources/SwipeMenuViewController/SwiftUI/SwipeMenu.swift @@ -0,0 +1,162 @@ +import SwiftUI + +/// A SwiftUI view that presents a scrollable tab bar above a horizontally paging +/// content area — the SwiftUI counterpart of ``SwipeMenuView``. +/// +/// The selected page is driven by a `Binding`, so moving to a page +/// programmatically (the equivalent of ``SwipeMenuView/jump(to:animated:)``) is a +/// matter of setting the binding. Tabs and pages come from `titles` and the `page` +/// view builder, appearance is configured through ``SwipeMenuOptions``, and the +/// optional `onWillChangeIndex`/`onDidChangeIndex` closures mirror the +/// ``SwipeMenuViewDelegate`` paging callbacks: +/// +/// ```swift +/// @State private var selection = 0 +/// +/// var body: some View { +/// SwipeMenu(selection: $selection, titles: ["One", "Two", "Three"]) { index in +/// Text("Page \(index)") +/// .frame(maxWidth: .infinity, maxHeight: .infinity) +/// } +/// } +/// ``` +/// +/// Like the UIKit view, the tab bar's selection indicator and title colors track +/// the swipe continuously (see ``SwipeMenuOptions/TabView-swift.struct/indicatorView-swift.property`` +/// and ``SwipeMenuOptions/TabView-swift.struct/interpolatesTextColorOnSwipe``), and +/// the `.flexible` tab bar auto-scrolls to keep the selection in view. Because +/// SwiftUI is declarative there is no `reloadData()`: changing `titles` or +/// `options` updates the menu in place. +@available(iOS 18.0, *) +public struct SwipeMenu: View { + + @Binding private var selection: Int + + private let titles: [String] + private let options: SwipeMenuOptions + private let onWillChangeIndex: ((_ fromIndex: Int, _ toIndex: Int) -> Void)? + private let onDidChangeIndex: ((_ fromIndex: Int, _ toIndex: Int) -> Void)? + private let page: (Int) -> Page + + /// The continuous page position driven by the content scroll offset, in pages + /// (`1.5` = halfway between the second and third page). The tab bar + /// interpolates the indicator and the title colors from this value. + @State private var progress: CGFloat + + @State private var contentPosition: ScrollPosition + + /// The index a tap- or binding-driven move started from, kept until the scroll + /// settles so `onDidChangeIndex` fires when the page actually finishes moving. + @State private var pendingFromIndex: Int? + + /// Creates a swipe menu with the given pages. + /// - Parameters: + /// - selection: A binding to the index of the front page. + /// - titles: The tab titles; one page is created per title. + /// - options: The appearance and behavior options. + /// - onWillChangeIndex: Called before the front page changes, with the current + /// and destination indices. + /// - onDidChangeIndex: Called after the front page has changed, with the + /// previous and current indices. + /// - page: A view builder that returns the content for the page at an index. + public init( + selection: Binding, + titles: [String], + options: SwipeMenuOptions = .init(), + onWillChangeIndex: ((_ fromIndex: Int, _ toIndex: Int) -> Void)? = nil, + onDidChangeIndex: ((_ fromIndex: Int, _ toIndex: Int) -> Void)? = nil, + @ViewBuilder page: @escaping (Int) -> Page + ) { + self._selection = selection + self.titles = titles + self.options = options + self.onWillChangeIndex = onWillChangeIndex + self.onDidChangeIndex = onDidChangeIndex + self.page = page + self._progress = State(initialValue: CGFloat(selection.wrappedValue)) + self._contentPosition = State(initialValue: ScrollPosition(id: selection.wrappedValue)) + } + + public var body: some View { + VStack(spacing: 0) { + SwipeTabBar( + selection: selection, + titles: titles, + options: options.tabView, + progress: progress, + onSelect: { index in + guard titles.indices.contains(index), index != selection else { return } + selection = index + } + ) + pager + } + .onChange(of: selection) { oldValue, newValue in + guard oldValue != newValue else { return } + // A selection committed by a swipe settling is already at the target + // offset; only tap- or binding-driven changes need a scroll. + guard SwipeMenuGeometry.landedPage(position: progress, pageCount: titles.count) != newValue else { return } + if let pending = pendingFromIndex { + // Finalize an interrupted move first so its `will` is paired with + // a `did` before this move begins. + pendingFromIndex = nil + onDidChangeIndex?(pending, oldValue) + } + onWillChangeIndex?(oldValue, newValue) + pendingFromIndex = oldValue + withAnimation(.easeInOut(duration: options.tabView.indicatorView.animationDuration)) { + contentPosition.scrollTo(id: newValue) + } + } + } + + // MARK: - Content + + private var pager: some View { + ScrollView(.horizontal) { + LazyHStack(spacing: 0) { + ForEach(titles.indices, id: \.self) { index in + page(index) + .containerRelativeFrame([.horizontal, .vertical]) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .scrollPosition($contentPosition) + .scrollIndicators(.hidden) + .scrollDisabled(!options.contentScrollView.isScrollEnabled) + .onScrollGeometryChange(for: CGFloat.self) { geometry in + guard geometry.containerSize.width > 0 else { return 0 } + return geometry.contentOffset.x / geometry.containerSize.width + } action: { _, newValue in + progress = SwipeMenuGeometry.clampedPosition(newValue, pageCount: titles.count) + } + .onScrollPhaseChange { _, newPhase in + guard newPhase == .idle else { return } + commitScrollEnd() + } + .background(options.contentScrollView.backgroundColor) + } + + /// Commits the page the content scroll settled on, firing the paging callbacks + /// exactly once per move — whether the move came from a swipe, a tab tap, or a + /// change to the selection binding. + private func commitScrollEnd() { + guard let landed = SwipeMenuGeometry.landedPage(position: progress, pageCount: titles.count) else { return } + + if let from = pendingFromIndex { + pendingFromIndex = nil + onDidChangeIndex?(from, selection) + // A drag can interrupt the programmatic scroll and land elsewhere. + guard landed != selection else { return } + } else if landed == selection { + return + } + + onWillChangeIndex?(selection, landed) + let from = selection + selection = landed + onDidChangeIndex?(from, landed) + } +} diff --git a/Sources/SwipeMenuViewController/SwiftUI/SwipeMenuGeometry.swift b/Sources/SwipeMenuViewController/SwiftUI/SwipeMenuGeometry.swift new file mode 100644 index 0000000..57a971f --- /dev/null +++ b/Sources/SwipeMenuViewController/SwiftUI/SwipeMenuGeometry.swift @@ -0,0 +1,101 @@ +import SwiftUI + +/// The pure geometry behind ``SwipeMenu`` and `SwipeTabBar`. +/// +/// Everything the SwiftUI menu draws or scrolls is derived from one continuous +/// value: the content's swipe position, in pages (`1.5` is halfway between the +/// second and third page). The functions here turn that value — together with +/// the measured tab frames — into the landed page, each title's selection +/// amount, the interpolated indicator frame, and the tab bar offset that keeps +/// the indicator in view. They are `nonisolated` and free of view state, so +/// they can be exercised directly in unit tests. +nonisolated enum SwipeMenuGeometry { + + /// Clamps a continuous page position to the valid range `0...(pageCount - 1)`. + /// + /// The content scroll reports offsets slightly past the first and last page + /// while bouncing; clamping keeps the indicator and the title colors pinned + /// to the outermost tabs, matching the UIKit ``TabView``. + /// - Parameters: + /// - position: The raw page position (content offset divided by page width). + /// - pageCount: The number of pages. + /// - Returns: The clamped position, or `0` when there are no pages. + static func clampedPosition(_ position: CGFloat, pageCount: Int) -> CGFloat { + guard pageCount > 0 else { return 0 } + return min(max(position, 0), CGFloat(pageCount - 1)) + } + + /// The page a scroll has landed on: the page nearest to `position`, clamped + /// to the valid range. + /// - Parameters: + /// - position: The continuous page position when the scroll settled. + /// - pageCount: The number of pages. + /// - Returns: The landed page index, or `nil` when there are no pages. + static func landedPage(position: CGFloat, pageCount: Int) -> Int? { + guard pageCount > 0 else { return nil } + return min(max(Int(position.rounded()), 0), pageCount - 1) + } + + /// How selected the tab at `index` is for a continuous page position: `1` + /// when the swipe rests on the tab, `0` from a full page away, and linear in + /// between. `SwipeTabBar` feeds this to `Color.mix(with:by:)` to crossfade + /// the title colors while swiping. + /// - Parameters: + /// - index: The tab index. + /// - position: The continuous page position. + /// - Returns: The selection amount in `0...1`. + static func selectionAmount(at index: Int, position: CGFloat) -> CGFloat { + return max(0, 1 - abs(position - CGFloat(index))) + } + + /// The horizontal frame of the selection indicator for a continuous page + /// position, interpolated between the tab the swipe is leaving and the tab + /// it is entering — the counterpart of the UIKit `TabView.moveIndicatorView`. + /// + /// Only `minX` and `width` are meaningful; the caller decides the vertical + /// placement (underline versus circle). + /// - Parameters: + /// - itemFrames: The measured frame of each tab, keyed by index, in the + /// tab container's coordinate space. + /// - itemCount: The number of tabs. + /// - position: The continuous page position; clamped to the valid range. + /// - padding: The indicator padding; `leading`/`trailing` inset the frame. + /// - Returns: The interpolated frame, or `nil` while a needed tab frame has + /// not been measured yet. + static func indicatorFrame( + itemFrames: [Int: CGRect], + itemCount: Int, + position: CGFloat, + padding: EdgeInsets + ) -> CGRect? { + guard itemCount > 0 else { return nil } + + let clamped = clampedPosition(position, pageCount: itemCount) + let lowerIndex = min(Int(clamped), itemCount - 1) + let upperIndex = min(lowerIndex + 1, itemCount - 1) + let fraction = clamped - CGFloat(lowerIndex) + + guard let lower = itemFrames[lowerIndex], let upper = itemFrames[upperIndex] else { return nil } + + let minX = lower.minX + (upper.minX - lower.minX) * fraction + padding.leading + let width = lower.width + (upper.width - lower.width) * fraction - padding.leading - padding.trailing + return CGRect(x: minX, y: 0, width: max(width, 0), height: 0) + } + + /// The scroll offset that keeps `centerX` in the middle of the visible tab + /// bar, clamped so the bar never scrolls past its edges — the counterpart of + /// the UIKit `TabView.focus(on:)`. + /// - Parameters: + /// - centerX: The point to center, in the scroll content's coordinate space. + /// - containerWidth: The visible width of the tab bar. + /// - contentWidth: The full width of the scrollable tab content. + /// - Returns: The clamped target offset. + static func focusOffset( + centeringOn centerX: CGFloat, + containerWidth: CGFloat, + contentWidth: CGFloat + ) -> CGFloat { + let maxOffset = max(contentWidth - containerWidth, 0) + return min(max(centerX - containerWidth / 2, 0), maxOffset) + } +} diff --git a/Sources/SwipeMenuViewController/SwiftUI/SwipeMenuOptions.swift b/Sources/SwipeMenuViewController/SwiftUI/SwipeMenuOptions.swift new file mode 100644 index 0000000..767da9f --- /dev/null +++ b/Sources/SwipeMenuViewController/SwiftUI/SwipeMenuOptions.swift @@ -0,0 +1,154 @@ +import SwiftUI + +// MARK: - SwipeMenuOptions + +/// The appearance and behavior options for the SwiftUI ``SwipeMenu`` view. +/// +/// `SwipeMenuOptions` mirrors ``SwipeMenuViewOptions`` with SwiftUI-native types: +/// colors are `Color`, fonts are `Font`, and insets are `EdgeInsets`. Options that +/// SwiftUI already owns at the container level — safe-area layout and clipping — +/// have no counterpart here; apply `ignoresSafeArea(_:edges:)` or `clipped()` +/// around a ``SwipeMenu`` instead. +@available(iOS 18.0, *) +public nonisolated struct SwipeMenuOptions: Sendable { + + public nonisolated struct TabView: Sendable { + + public nonisolated enum Style: Sendable { + /// Tabs size themselves to their content and scroll horizontally. + case flexible + /// Tabs share the available width equally. + case segmented + } + + public nonisolated enum Indicator: Sendable { + case underline + case circle + case none + } + + public nonisolated struct ItemView: Sendable { + /// ItemView width used when ``TabView/adjustsItemViewWidth`` is `false`. + /// Defaults to `100.0`. + public var width: CGFloat = 100.0 + + /// The horizontal margin added on both sides of a self-sizing item's title. + /// Defaults to `5.0`. + public var margin: CGFloat = 5.0 + + /// ItemView font. Defaults to a bold 14 pt system font. + public var font: Font = .system(size: 14, weight: .bold) + + /// ItemView font used while the item is selected. Defaults to a bold 14 pt + /// system font, matching ``font`` so the title font does not change on + /// selection unless you set this. + /// + /// This changes the selected title's appearance only; in the `.flexible` + /// style each item's width is still measured with ``font``, so a larger + /// `selectedFont` may be truncated. + public var selectedFont: Font = .system(size: 14, weight: .bold) + + /// ItemView text color. Defaults to a light gray. + public var textColor: Color = Color(red: 170 / 255, green: 170 / 255, blue: 170 / 255) + + /// ItemView selected text color. Defaults to `.black`. + public var selectedTextColor: Color = .black + + /// The maximum number of lines used to render the title. Use `0` to allow as + /// many lines as the title needs. Titles that do not fit are truncated. + /// Defaults to `1`. + /// + /// This is most useful with the `.segmented` style, where each item has a + /// fixed width and a long title would otherwise be truncated onto a single line. + public var numberOfLines: Int = 1 + } + + public nonisolated struct IndicatorView: Sendable { + + public nonisolated struct Underline: Sendable { + /// The underline thickness when the indicator is `.underline`. Defaults to `2.0`. + public var height: CGFloat = 2.0 + + /// The corner radius of the underline when the indicator is `.underline`. + /// Defaults to `0` (square corners). Set it to half of `height` for a pill shape. + public var cornerRadius: CGFloat = 0 + } + + public nonisolated struct Circle: Sendable { + /// The corner radius of the highlight when the indicator is `.circle`. + /// Defaults to `nil`, which uses half the highlight's height (a capsule). + public var cornerRadius: CGFloat? + } + + /// The padding around the indicator view. Defaults to zero insets. + public var padding: EdgeInsets = EdgeInsets() + + /// The indicator view's background color. Defaults to `.black`. + public var backgroundColor: Color = .black + + /// The duration of the indicator's move animation, in seconds. Defaults to `0.3`. + public var animationDuration: Double = 0.3 + + /// Whether the indicator view continuously tracks the finger while the content + /// is swiped. When `false`, it animates to the destination tab once the page + /// changes instead. Defaults to `true`. + public var isAnimationOnSwipeEnabled: Bool = true + + /// Underline style options. + public var underline = Underline() + + /// Circle style options. + public var circle = Circle() + } + + /// TabView height. Defaults to `44.0`. + public var height: CGFloat = 44.0 + + /// TabView side margin. Defaults to `0.0`. + public var margin: CGFloat = 0.0 + + /// TabView background color. Defaults to `.clear`. + public var backgroundColor: Color = .clear + + /// TabView style. Defaults to `.flexible`. Style type has [`.flexible` , `.segmented`]. + public var style: Style = .flexible + + /// The selection indicator drawn on the selected tab: `.underline`, `.circle`, + /// or `.none`. Defaults to `.underline`. + public var indicator: Indicator = .underline + + /// Whether each `.flexible` item is sized to fit its title (plus ``ItemView/margin`` + /// on both sides) instead of using the fixed ``ItemView/width``. Defaults to `true`. + public var adjustsItemViewWidth: Bool = true + + /// Whether tab titles crossfade between ``ItemView/textColor`` and + /// ``ItemView/selectedTextColor`` in proportion to the swipe progress. When `false`, + /// titles switch color only when the selection changes. Defaults to `true`. + public var interpolatesTextColorOnSwipe: Bool = true + + /// ItemView options + public var itemView = ItemView() + + /// IndicatorView options + public var indicatorView = IndicatorView() + + public init() {} + } + + public nonisolated struct ContentScrollView: Sendable { + + /// ContentScrollView backgroundColor. Defaults to `.clear`. + public var backgroundColor: Color = .clear + + /// ContentScrollView scroll enabled. Defaults to `true`. + public var isScrollEnabled: Bool = true + } + + /// TabView options + public var tabView = TabView() + + /// ContentScrollView options + public var contentScrollView = ContentScrollView() + + public init() {} +} diff --git a/Sources/SwipeMenuViewController/SwiftUI/SwipeTabBar.swift b/Sources/SwipeMenuViewController/SwiftUI/SwipeTabBar.swift new file mode 100644 index 0000000..204e093 --- /dev/null +++ b/Sources/SwipeMenuViewController/SwiftUI/SwipeTabBar.swift @@ -0,0 +1,238 @@ +import SwiftUI + +/// The scrollable tab bar displayed at the top of a ``SwipeMenu`` — the SwiftUI +/// counterpart of the UIKit ``TabView``. +/// +/// The bar lays out one tab item per title, draws the selection indicator +/// configured by ``SwipeMenuOptions/TabView-swift.struct``, and — in the +/// `.flexible` style — scrolls itself to keep the indicator in view while the +/// content is swiped. It holds no paging state of its own: the parent passes in +/// the committed `selection` and the continuous `progress`, and reacts to tab +/// taps through `onSelect`. All interpolation is delegated to +/// ``SwipeMenuGeometry`` so it can be unit tested. +@available(iOS 18.0, *) +struct SwipeTabBar: View { + + /// The container/content widths of the bar's scroll view, measured so the + /// focus offset can be clamped to the scrollable range. + nonisolated struct ScrollMetrics: Equatable, Sendable { + var containerWidth: CGFloat = 0 + var contentWidth: CGFloat = 0 + } + + /// The name of the coordinate space the tab items are measured in: the tab + /// container that also hosts the selection indicator. + nonisolated static let coordinateSpaceName = "SwipeMenu.TabBar" + + /// The index of the selected tab. + let selection: Int + + /// The tab titles; one item is laid out per title. + let titles: [String] + + /// The appearance and behavior options for the bar. + let options: SwipeMenuOptions.TabView + + /// The continuous page position of the content scroll, in pages. + let progress: CGFloat + + /// Called when a tab is tapped, with the tapped index. + let onSelect: (Int) -> Void + + /// The frame of each tab item in the tab container's coordinate space. + @State private var itemFrames: [Int: CGRect] = [:] + + @State private var barPosition = ScrollPosition() + @State private var barMetrics = ScrollMetrics() + + var body: some View { + Group { + switch options.style { + case .flexible: + flexibleBar + case .segmented: + container + .padding(.horizontal, options.margin) + } + } + .frame(height: options.height) + .background(options.backgroundColor) + } + + private var flexibleBar: some View { + ScrollView(.horizontal) { + container + .padding(.horizontal, options.margin) + } + .scrollIndicators(.hidden) + .scrollPosition($barPosition) + .onScrollGeometryChange(for: ScrollMetrics.self) { geometry in + ScrollMetrics( + containerWidth: geometry.containerSize.width, + contentWidth: geometry.contentSize.width) + } action: { _, newValue in + barMetrics = newValue + } + .onChange(of: focusTargetX) { _, newValue in + guard let newValue else { return } + if options.indicatorView.isAnimationOnSwipeEnabled { + // The swipe (or the tap-driven paging animation) moves the target a + // little each frame, so following it unanimated tracks the finger. + barPosition.scrollTo(x: newValue) + } else { + withAnimation(.easeInOut(duration: options.indicatorView.animationDuration)) { + barPosition.scrollTo(x: newValue) + } + } + } + } + + private var container: some View { + ZStack(alignment: .topLeading) { + if options.indicator == .circle { + indicator + } + HStack(spacing: 0) { + ForEach(titles.indices, id: \.self) { index in + itemView(at: index) + } + } + .frame(height: itemsHeight) + if options.indicator == .underline { + indicator + } + } + .frame(height: options.height, alignment: .topLeading) + .coordinateSpace(.named(Self.coordinateSpaceName)) + } + + // MARK: - Items + + /// The height of the tab item area. The underline (and its bottom padding) is + /// laid out below the items, matching the UIKit ``TabView``. + private var itemsHeight: CGFloat { + switch options.indicator { + case .underline: + return max(options.height - options.indicatorView.underline.height - options.indicatorView.padding.bottom, 0) + case .circle, .none: + return options.height + } + } + + @ViewBuilder + private func itemView(at index: Int) -> some View { + let label = itemLabel(at: index) + Group { + switch options.style { + case .flexible: + if options.adjustsItemViewWidth { + label.padding(.horizontal, options.itemView.margin) + } else { + label.frame(width: options.itemView.width) + } + case .segmented: + label.frame(maxWidth: .infinity) + } + } + .frame(maxHeight: .infinity) + .contentShape(.rect) + .onTapGesture { onSelect(index) } + .accessibilityAddTraits(index == selection ? [.isButton, .isSelected] : .isButton) + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .named(Self.coordinateSpaceName)) + } action: { newValue in + itemFrames[index] = newValue + } + } + + @ViewBuilder + private func itemLabel(at index: Int) -> some View { + let item = options.itemView + let lineLimit = item.numberOfLines == 0 ? nil : item.numberOfLines + // The hidden text sizes the item with the base font so, as in the UIKit + // `TabView`, swapping in `selectedFont` never changes the layout. + Text(titles[index]) + .font(item.font) + .lineLimit(lineLimit) + .multilineTextAlignment(.center) + .hidden() + .overlay { + Text(titles[index]) + .font(index == selection ? item.selectedFont : item.font) + .foregroundStyle(titleColor(at: index)) + .lineLimit(lineLimit) + .multilineTextAlignment(.center) + } + } + + private func titleColor(at index: Int) -> Color { + let item = options.itemView + guard options.interpolatesTextColorOnSwipe, options.indicatorView.isAnimationOnSwipeEnabled else { + return index == selection ? item.selectedTextColor : item.textColor + } + let amount = SwipeMenuGeometry.selectionAmount(at: index, position: progress) + return item.textColor.mix(with: item.selectedTextColor, by: Double(amount)) + } + + // MARK: - Indicator + + @ViewBuilder + private var indicator: some View { + let frame = indicatorFrame ?? .zero + let indicatorView = options.indicatorView + switch options.indicator { + case .underline: + RoundedRectangle(cornerRadius: indicatorView.underline.cornerRadius) + .fill(indicatorView.backgroundColor) + .frame(width: frame.width, height: indicatorView.underline.height) + .offset(x: frame.minX, y: itemsHeight) + .animation(indicatorAnimation, value: selection) + case .circle: + let height = max(itemsHeight - indicatorView.padding.top - indicatorView.padding.bottom, 0) + RoundedRectangle(cornerRadius: indicatorView.circle.cornerRadius ?? height / 2) + .fill(indicatorView.backgroundColor) + .frame(width: frame.width, height: height) + .offset(x: frame.minX, y: (itemsHeight - height) / 2) + .animation(indicatorAnimation, value: selection) + case .none: + EmptyView() + } + } + + /// Animates selection-driven indicator moves when swipe tracking is off. With + /// tracking on, the indicator follows `progress`, which the scroll itself + /// animates frame by frame. + private var indicatorAnimation: Animation? { + options.indicatorView.isAnimationOnSwipeEnabled + ? nil + : .easeInOut(duration: options.indicatorView.animationDuration) + } + + /// The indicator's horizontal frame, or `nil` while the tabs it interpolates + /// between have not been measured yet. With swipe tracking off, the frame + /// follows the committed selection instead of the continuous progress. + private var indicatorFrame: CGRect? { + let position = options.indicatorView.isAnimationOnSwipeEnabled ? progress : CGFloat(selection) + return SwipeMenuGeometry.indicatorFrame( + itemFrames: itemFrames, + itemCount: titles.count, + position: position, + padding: options.indicatorView.padding) + } + + // MARK: - Focus + + /// The scroll offset that centers the indicator in the visible tab bar, + /// clamped to the scrollable range — the counterpart of the UIKit + /// `TabView.focus(on:)`. `nil` while the bar or the items are not laid out + /// yet, or when the `.segmented` style makes the bar unscrollable. + private var focusTargetX: CGFloat? { + guard options.style == .flexible, barMetrics.containerWidth > 0 else { return nil } + guard let frame = indicatorFrame, frame.width > 0 else { return nil } + + return SwipeMenuGeometry.focusOffset( + centeringOn: frame.midX + options.margin, + containerWidth: barMetrics.containerWidth, + contentWidth: barMetrics.contentWidth) + } +} diff --git a/Sources/SwipeMenuViewController/SwipeMenuViewController.docc/SwipeMenuInSwiftUI.md b/Sources/SwipeMenuViewController/SwipeMenuViewController.docc/SwipeMenuInSwiftUI.md new file mode 100644 index 0000000..ce6c12e --- /dev/null +++ b/Sources/SwipeMenuViewController/SwipeMenuViewController.docc/SwipeMenuInSwiftUI.md @@ -0,0 +1,110 @@ +# SwipeMenu in SwiftUI + +Drive the same swipe-based paging UI from SwiftUI with a selection binding. + +@Metadata { + @PageKind(article) +} + +## Overview + +``SwipeMenu`` is the SwiftUI counterpart of ``SwipeMenuView``, available on iOS 18 and later. It +presents the same UI — a tab bar above a horizontally paging content area — with a SwiftUI-native +API: the selected page is a `Binding`, pages come from a view builder, and the appearance is +configured with ``SwipeMenuOptions``. + +The signature behaviors of the UIKit view carry over. The selection indicator interpolates its +position and width between the adjacent tabs while you swipe, the tab titles crossfade between +their normal and selected colors in proportion to the swipe progress, and the `.flexible` tab bar +scrolls itself to keep the selection in view. + +## Displaying pages + +Provide one title per page and a view builder that returns the page for an index. There is no data +source or `reloadData()`: changing `titles` or `options` re-renders the menu in place. + +```swift +import SwiftUI +import SwipeMenuViewController + +struct ContentView: View { + + @State private var selection = 0 + + private let titles = ["Sports", "News", "Weather"] + + var body: some View { + SwipeMenu(selection: $selection, titles: titles) { index in + Text(titles[index]) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} +``` + +Setting the binding is the equivalent of ``SwipeMenuView/jump(to:animated:)`` — the content +animates to the new page and the tab bar follows: + +```swift +Button("Show News") { selection = 1 } +``` + +## Customizing appearance + +``SwipeMenuOptions`` mirrors ``SwipeMenuViewOptions`` with SwiftUI-native types: colors are +`Color`, fonts are `Font`, and insets are `EdgeInsets`. Build one, set the properties you care +about, and pass it to ``SwipeMenu/init(selection:titles:options:onWillChangeIndex:onDidChangeIndex:page:)``. + +```swift +var options = SwipeMenuOptions() +options.tabView.style = .segmented +options.tabView.indicator = .circle +options.tabView.itemView.textColor = Color(.secondaryLabel) +options.tabView.itemView.selectedTextColor = Color(.systemBackground) +options.tabView.indicatorView.backgroundColor = Color(.label) + +SwipeMenu(selection: $selection, titles: titles, options: options) { index in + PageView(title: titles[index]) +} +``` + +The option groups match the UIKit ones described in : `tabView` for the +bar, `tabView.itemView` for each tab, `tabView.indicatorView` for the selection indicator, and +`contentScrollView` for the paging content. + +## Observing page changes + +The `onWillChangeIndex` and `onDidChangeIndex` closures mirror the +``SwipeMenuViewDelegate/swipeMenuView(_:willChangeIndexFrom:to:)`` and +``SwipeMenuViewDelegate/swipeMenuView(_:didChangeIndexFrom:to:)`` callbacks. They fire exactly once +per move, whether the move came from a swipe, a tab tap, or a change to the selection binding: + +```swift +SwipeMenu( + selection: $selection, + titles: titles, + onWillChangeIndex: { fromIndex, toIndex in + print("will move from \(fromIndex) to \(toIndex)") + }, + onDidChangeIndex: { fromIndex, toIndex in + print("did move from \(fromIndex) to \(toIndex)") + } +) { index in + PageView(title: titles[index]) +} +``` + +There are no counterparts to the `viewWillSetupAt`/`viewDidSetupAt` callbacks; use `onAppear` if +you need to react to the menu appearing. + +## Differences from the UIKit view + +`SwipeMenu` leans on SwiftUI where SwiftUI already owns the behavior, so a few +``SwipeMenuViewOptions`` knobs intentionally have no counterpart on ``SwipeMenuOptions``: + +- `isSafeAreaEnabled` and `clipsToBounds` are layout concerns of the container. Apply + `ignoresSafeArea(_:edges:)` or `clipped()` around the `SwipeMenu` instead. +- The circle indicator exposes `cornerRadius` only; there is no `maskedCorners`. +- Programmatic moves are always animated, and the paging callbacks fire when the scroll settles + rather than at the page boundary. +- Pages are built lazily as they scroll into view, not all at once. diff --git a/Sources/SwipeMenuViewController/SwipeMenuViewController.docc/SwipeMenuViewController.md b/Sources/SwipeMenuViewController/SwipeMenuViewController.docc/SwipeMenuViewController.md index b540d65..9f1c9b5 100644 --- a/Sources/SwipeMenuViewController/SwipeMenuViewController.docc/SwipeMenuViewController.md +++ b/Sources/SwipeMenuViewController/SwipeMenuViewController.docc/SwipeMenuViewController.md @@ -11,7 +11,8 @@ protocols modeled on UIKit's, and you tune its appearance with ``SwipeMenuViewOp Use ``SwipeMenuView`` when you want to embed the paging UI inside an existing view hierarchy, or subclass ``SwipeMenuViewController`` to get a container view controller that drives the paging -from its child view controllers. +from its child view controllers. In SwiftUI, use ``SwipeMenu`` (iOS 18+), which drives the same +UI from a selection binding and is configured with ``SwipeMenuOptions``. ## Topics @@ -19,6 +20,7 @@ from its child view controllers. - - +- ### Menu View @@ -31,6 +33,11 @@ from its child view controllers. - ``SwipeMenuViewController`` +### SwiftUI + +- ``SwipeMenu`` +- ``SwipeMenuOptions`` + ### Tabs - ``TabView`` diff --git a/Tests/SwipeMenuViewControllerTests/SwipeMenuGeometryTests.swift b/Tests/SwipeMenuViewControllerTests/SwipeMenuGeometryTests.swift new file mode 100644 index 0000000..d90907e --- /dev/null +++ b/Tests/SwipeMenuViewControllerTests/SwipeMenuGeometryTests.swift @@ -0,0 +1,170 @@ +import SwiftUI +import Testing + +@testable import SwipeMenuViewController + +@Suite("SwipeMenuGeometry") +struct SwipeMenuGeometryTests { + + /// Frames matching a flexible bar with three differently sized tabs laid out + /// end to end: widths 80, 120, and 60. + private let itemFrames: [Int: CGRect] = [ + 0: CGRect(x: 0, y: 0, width: 80, height: 40), + 1: CGRect(x: 80, y: 0, width: 120, height: 40), + 2: CGRect(x: 200, y: 0, width: 60, height: 40), + ] + + // MARK: - clampedPosition + + @Test("Positions inside the page range pass through unchanged") + func inRangePositionsPassThrough() { + #expect(SwipeMenuGeometry.clampedPosition(0, pageCount: 3) == 0) + #expect(SwipeMenuGeometry.clampedPosition(0.75, pageCount: 3) == 0.75) + #expect(SwipeMenuGeometry.clampedPosition(2, pageCount: 3) == 2) + } + + @Test("Positions outside the page range clamp to the outermost pages") + func outOfRangePositionsClamp() { + #expect(SwipeMenuGeometry.clampedPosition(-0.5, pageCount: 3) == 0) + #expect(SwipeMenuGeometry.clampedPosition(7, pageCount: 3) == 2) + } + + @Test("An empty menu clamps every position to zero") + func emptyMenuClampsToZero() { + #expect(SwipeMenuGeometry.clampedPosition(1.5, pageCount: 0) == 0) + } + + // MARK: - landedPage + + @Test("The landed page is the page nearest to the settled position") + func landedPageIsNearest() { + #expect(SwipeMenuGeometry.landedPage(position: 0, pageCount: 3) == 0) + #expect(SwipeMenuGeometry.landedPage(position: 1.4, pageCount: 3) == 1) + #expect(SwipeMenuGeometry.landedPage(position: 1.5, pageCount: 3) == 2) + } + + @Test("Overscrolled positions land on the outermost pages") + func overscrollLandsOnOutermostPages() { + #expect(SwipeMenuGeometry.landedPage(position: -0.4, pageCount: 3) == 0) + #expect(SwipeMenuGeometry.landedPage(position: 2.7, pageCount: 3) == 2) + } + + @Test("An empty menu has no landed page") + func emptyMenuHasNoLandedPage() { + #expect(SwipeMenuGeometry.landedPage(position: 0, pageCount: 0) == nil) + } + + // MARK: - selectionAmount + + @Test("A tab the swipe rests on is fully selected") + func restingTabIsFullySelected() { + #expect(SwipeMenuGeometry.selectionAmount(at: 1, position: 1) == 1) + } + + @Test("Selection fades linearly between the two adjacent tabs") + func selectionFadesLinearly() { + #expect(SwipeMenuGeometry.selectionAmount(at: 1, position: 1.25) == 0.75) + #expect(SwipeMenuGeometry.selectionAmount(at: 2, position: 1.25) == 0.25) + } + + @Test("Tabs a full page or more away are fully deselected") + func distantTabsAreDeselected() { + #expect(SwipeMenuGeometry.selectionAmount(at: 0, position: 1) == 0) + #expect(SwipeMenuGeometry.selectionAmount(at: 0, position: 2.5) == 0) + } + + // MARK: - indicatorFrame + + @Test("At rest the indicator matches the selected tab") + func indicatorMatchesTabAtRest() { + let frame = SwipeMenuGeometry.indicatorFrame( + itemFrames: itemFrames, itemCount: 3, position: 1, padding: EdgeInsets()) + + #expect(frame?.minX == 80) + #expect(frame?.width == 120) + } + + @Test("Halfway through a swipe the indicator interpolates position and width") + func indicatorInterpolatesMidSwipe() { + let frame = SwipeMenuGeometry.indicatorFrame( + itemFrames: itemFrames, itemCount: 3, position: 0.5, padding: EdgeInsets()) + + #expect(frame?.minX == 40) + #expect(frame?.width == 100) + } + + @Test("Padding insets the indicator inside its tab") + func paddingInsetsIndicator() { + let padding = EdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 6) + let frame = SwipeMenuGeometry.indicatorFrame( + itemFrames: itemFrames, itemCount: 3, position: 0, padding: padding) + + #expect(frame?.minX == 4) + #expect(frame?.width == 70) + } + + @Test("Positions beyond the ends pin the indicator to the outermost tabs") + func indicatorClampsToOutermostTabs() { + let below = SwipeMenuGeometry.indicatorFrame( + itemFrames: itemFrames, itemCount: 3, position: -1, padding: EdgeInsets()) + #expect(below?.minX == 0) + #expect(below?.width == 80) + + let beyond = SwipeMenuGeometry.indicatorFrame( + itemFrames: itemFrames, itemCount: 3, position: 5, padding: EdgeInsets()) + #expect(beyond?.minX == 200) + #expect(beyond?.width == 60) + } + + @Test("The indicator hides until every needed tab frame is measured") + func indicatorHidesWithoutMeasuredFrames() { + let partialFrames: [Int: CGRect] = [0: CGRect(x: 0, y: 0, width: 80, height: 40)] + + let frame = SwipeMenuGeometry.indicatorFrame( + itemFrames: partialFrames, itemCount: 3, position: 0.5, padding: EdgeInsets()) + #expect(frame == nil) + + let empty = SwipeMenuGeometry.indicatorFrame( + itemFrames: [:], itemCount: 0, position: 0, padding: EdgeInsets()) + #expect(empty == nil) + } + + @Test("Padding wider than the tab collapses the indicator instead of inverting it") + func oversizedPaddingCollapsesIndicator() { + let padding = EdgeInsets(top: 0, leading: 50, bottom: 0, trailing: 50) + let frame = SwipeMenuGeometry.indicatorFrame( + itemFrames: itemFrames, itemCount: 3, position: 2, padding: padding) + + #expect(frame?.width == 0) + } + + // MARK: - focusOffset + + @Test("The focus offset centers the point in the visible bar") + func focusOffsetCenters() { + let offset = SwipeMenuGeometry.focusOffset( + centeringOn: 300, containerWidth: 200, contentWidth: 1000) + #expect(offset == 200) + } + + @Test("The focus offset clamps at the leading edge") + func focusOffsetClampsAtLeadingEdge() { + let offset = SwipeMenuGeometry.focusOffset( + centeringOn: 50, containerWidth: 200, contentWidth: 1000) + #expect(offset == 0) + } + + @Test("The focus offset clamps at the trailing edge") + func focusOffsetClampsAtTrailingEdge() { + let offset = SwipeMenuGeometry.focusOffset( + centeringOn: 950, containerWidth: 200, contentWidth: 1000) + #expect(offset == 800) + } + + @Test("Content narrower than the bar never scrolls") + func narrowContentNeverScrolls() { + let offset = SwipeMenuGeometry.focusOffset( + centeringOn: 100, containerWidth: 200, contentWidth: 150) + #expect(offset == 0) + } +} diff --git a/Tests/SwipeMenuViewControllerTests/SwipeMenuOptionsTests.swift b/Tests/SwipeMenuViewControllerTests/SwipeMenuOptionsTests.swift new file mode 100644 index 0000000..4544538 --- /dev/null +++ b/Tests/SwipeMenuViewControllerTests/SwipeMenuOptionsTests.swift @@ -0,0 +1,81 @@ +import SwiftUI +import Testing + +@testable import SwipeMenuViewController + +/// Constructs a `SwipeMenuOptions` from a `nonisolated` context. That this +/// compiles proves the type is usable outside the main actor (i.e. it really is +/// `Sendable` and not main-actor isolated). +@available(iOS 18.0, *) +private nonisolated func makeOptionsFromNonisolatedContext() -> SwipeMenuOptions { + return SwipeMenuOptions() +} + +@Suite("SwipeMenuOptions") +struct SwipeMenuOptionsTests { + + @available(iOS 18.0, *) + @Test("TabView documented defaults") + func tabViewDefaults() { + let options = SwipeMenuOptions() + + #expect(options.tabView.height == 44.0) + #expect(options.tabView.margin == 0.0) + #expect(options.tabView.backgroundColor == .clear) + #expect(options.tabView.style == .flexible) + #expect(options.tabView.indicator == .underline) + #expect(options.tabView.adjustsItemViewWidth == true) + #expect(options.tabView.interpolatesTextColorOnSwipe == true) + } + + @available(iOS 18.0, *) + @Test("ItemView documented defaults") + func itemViewDefaults() { + let options = SwipeMenuOptions() + + #expect(options.tabView.itemView.width == 100.0) + #expect(options.tabView.itemView.margin == 5.0) + #expect(options.tabView.itemView.font == .system(size: 14, weight: .bold)) + // Defaults to the same font as `font`, so selection does not change the title font by default. + #expect(options.tabView.itemView.selectedFont == .system(size: 14, weight: .bold)) + #expect(options.tabView.itemView.textColor == Color(red: 170 / 255, green: 170 / 255, blue: 170 / 255)) + #expect(options.tabView.itemView.selectedTextColor == .black) + #expect(options.tabView.itemView.numberOfLines == 1) + } + + @available(iOS 18.0, *) + @Test("IndicatorView documented defaults") + func indicatorViewDefaults() { + let options = SwipeMenuOptions() + + #expect(options.tabView.indicatorView.underline.height == 2.0) + #expect(options.tabView.indicatorView.underline.cornerRadius == 0) + #expect(options.tabView.indicatorView.circle.cornerRadius == nil) + #expect(options.tabView.indicatorView.padding == EdgeInsets()) + #expect(options.tabView.indicatorView.backgroundColor == .black) + #expect(options.tabView.indicatorView.animationDuration == 0.3) + #expect(options.tabView.indicatorView.isAnimationOnSwipeEnabled == true) + } + + @available(iOS 18.0, *) + @Test("ContentScrollView documented defaults") + func contentScrollViewDefaults() { + let options = SwipeMenuOptions() + + #expect(options.contentScrollView.backgroundColor == .clear) + #expect(options.contentScrollView.isScrollEnabled == true) + } + + @available(iOS 18.0, *) + @Test("Options are Sendable") + func optionsAreSendable() { + // Compile-time proof that the value can be treated as `any Sendable`. + let sendable: any Sendable = SwipeMenuOptions() + #expect(sendable is SwipeMenuOptions) + + // Compile-time proof that a nonisolated context can construct the + // options (the call itself still runs on the main actor here). + let fromNonisolated = makeOptionsFromNonisolatedContext() + #expect(fromNonisolated.tabView.height == 44.0) + } +} From 2b94e89e2f5c8cd7faa150f24c6a76f2e24f0ba3 Mon Sep 17 00:00:00 2001 From: Yusuke Morishita Date: Fri, 10 Jul 2026 08:13:04 -0700 Subject: [PATCH 2/2] Report the actually landed page when a drag interrupts a programmatic scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commitScrollEnd() fired onDidChangeIndex with the intended selection and then a second will/did pair for the page the drag really settled on, so a single physical settle produced two didChange calls — the first for a transition that never appeared on screen. The settle handler now commits the landed page directly and fires one pair per move; when interrupted, the did's destination can differ from the one its will announced, which the doc comment now spells out. --- .../SwiftUI/SwipeMenu.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/SwipeMenuViewController/SwiftUI/SwipeMenu.swift b/Sources/SwipeMenuViewController/SwiftUI/SwipeMenu.swift index abc7656..e3dd2d1 100644 --- a/Sources/SwipeMenuViewController/SwiftUI/SwipeMenu.swift +++ b/Sources/SwipeMenuViewController/SwiftUI/SwipeMenu.swift @@ -142,18 +142,27 @@ public struct SwipeMenu: View { /// Commits the page the content scroll settled on, firing the paging callbacks /// exactly once per move — whether the move came from a swipe, a tab tap, or a /// change to the selection binding. + /// + /// When a drag interrupts a programmatic scroll, the move's `onDidChangeIndex` + /// reports the page the content actually landed on, which can differ from the + /// destination its `onWillChangeIndex` announced. private func commitScrollEnd() { guard let landed = SwipeMenuGeometry.landedPage(position: progress, pageCount: titles.count) else { return } if let from = pendingFromIndex { + // A tap- or binding-driven move settled. A drag can interrupt the + // programmatic scroll and land elsewhere, so commit and report the + // landed page rather than claiming the intended selection was reached. pendingFromIndex = nil - onDidChangeIndex?(from, selection) - // A drag can interrupt the programmatic scroll and land elsewhere. - guard landed != selection else { return } - } else if landed == selection { + if selection != landed { + selection = landed + } + onDidChangeIndex?(from, landed) return } + guard landed != selection else { return } + onWillChangeIndex?(selection, landed) let from = selection selection = landed