Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
34 changes: 32 additions & 2 deletions Example/Example/MenuViewController.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import SwiftUI
import SwipeMenuViewController
import UIKit

Expand All @@ -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 = [
Expand All @@ -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() {
Expand All @@ -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
Expand All @@ -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),
])
}

Expand All @@ -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.
Expand Down
76 changes: 76 additions & 0 deletions Example/Example/SwiftUIMenuView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
90 changes: 90 additions & 0 deletions Example/Example/SwiftUIOptionsView.swift
Original file line number Diff line number Diff line change
@@ -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<SwipeMenuSettings.Style> {
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)
}
43 changes: 43 additions & 0 deletions Example/Example/SwipeMenuSettings+SwiftUI.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
68 changes: 68 additions & 0 deletions Example/ExampleTests/SwipeMenuSettingsSwiftUITests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 9 additions & 4 deletions Example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading