From bae83ba6b8c94078283bbfe9a946f68a6ffdacfe Mon Sep 17 00:00:00 2001 From: Oliver Michalak Date: Sat, 22 Aug 2026 11:10:46 +0200 Subject: [PATCH 1/8] Lower Swift tools requirement to 6.0 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 1bdba7c..e8f0a17 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.4 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription From a85d9763e9d777bb7cf02cce67d627f38522014e Mon Sep 17 00:00:00 2001 From: Oliver Michalak Date: Sat, 22 Aug 2026 11:37:24 +0200 Subject: [PATCH 2/8] minimize Sendable, which eases ViewBuilder configuration --- Sources/AboutKit/Models/AKConfiguration.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/AboutKit/Models/AKConfiguration.swift b/Sources/AboutKit/Models/AKConfiguration.swift index e7b1055..5d83c7f 100644 --- a/Sources/AboutKit/Models/AKConfiguration.swift +++ b/Sources/AboutKit/Models/AKConfiguration.swift @@ -8,7 +8,7 @@ import Foundation /// A custom struct containing details for AboutKit. -public struct AKConfiguration: Sendable { +public struct AKConfiguration { /// A custom struct of type `AKMyApp` containing details about the current app. public let app: AKMyApp @@ -42,6 +42,7 @@ public struct AKConfiguration: Sendable { } /// An example `AKConfiguration` to be used in SwiftUI previews. + @MainActor static let example = AKConfiguration( app: .example, otherApps: [.example], From f71c195101f53a5e66a845b2ed58cf738eab270b Mon Sep 17 00:00:00 2001 From: Oliver Michalak Date: Sat, 22 Aug 2026 12:04:50 +0200 Subject: [PATCH 3/8] introduce AKSection and AKEntry to customize entries at will, support defaults to keep current behaviour alive --- README.md | 49 +++++++++++ Sources/AboutKit/AboutAppView.swift | 49 +++++++++-- Sources/AboutKit/MacAboutAppView.swift | 47 ++++++++-- Sources/AboutKit/Models/AKConfiguration.swift | 8 +- Sources/AboutKit/Models/AKEntry.swift | 51 +++++++++++ Sources/AboutKit/Models/AKSection.swift | 31 +++++++ Sources/AboutKit/Views/AKEntryRow.swift | 62 +++++++++++++ Sources/AboutKit/WatchTVAboutAppView.swift | 43 +++++++++- Tests/AboutKitTests/AboutKitTests.swift | 86 +++++++++++++++++-- 9 files changed, 402 insertions(+), 24 deletions(-) create mode 100644 Sources/AboutKit/Models/AKEntry.swift create mode 100644 Sources/AboutKit/Models/AKSection.swift create mode 100644 Sources/AboutKit/Views/AKEntryRow.swift diff --git a/README.md b/README.md index d71af3e..bf6309d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ For users who previously used AboutKit for showing the features list, please use 2. [Integration](#integration) 3. [Usage](#usage) - [AKConfiguration](#akconfiguration) + - [AKSection](#aksection) + - [AKEntry](#akentry) - [AKMyApp](#akmyapp) - [AKDeveloper](#akdeveloper) - [AKProfile](#akprofile) @@ -78,6 +80,53 @@ let configuration = AKConfiguration( ) ``` +The default section order matches the standard AboutKit layout. Supply a custom `sections` array to reorder built-in sections or insert your own sections. + +### AKSection + +`AKSection` controls the sections and their order in the AboutKit form. The standard order is available as `AKSection.defaultOrder`. A custom section can contain one or more `AKEntry` values: + +```swift +let sections: [AKSection] = [ + .header, + .contact, + .custom( + title: "Purchases", + entries: [purchaseEntry, tipJarEntry] + ), + .developerProfiles, + .appProfiles, + .sharingAndReview, + .legalAndAcknowledgements, + .testFlight, + .otherApps +] +``` + +Pass the order to `AKConfiguration` using its `sections` parameter. + +### AKEntry + +`AKEntry` creates a row with a title and SF Symbol that presents any SwiftUI destination using a navigation push or sheet: + +```swift +let purchaseEntry = AKEntry.navigation( + "Purchase Options", + systemImage: "cart" +) { + PurchaseOptionsView() +} + +let tipJarEntry = AKEntry.sheet( + "Tip Jar", + systemImage: "heart" +) { + TipJarView() +} +``` + +Sheet destinations are responsible for providing any navigation container or dismissal controls they require. + ### AKMyApp This is a struct containing details about the current app. It can be created like so: diff --git a/Sources/AboutKit/AboutAppView.swift b/Sources/AboutKit/AboutAppView.swift index 921e237..cc68594 100644 --- a/Sources/AboutKit/AboutAppView.swift +++ b/Sources/AboutKit/AboutAppView.swift @@ -27,10 +27,29 @@ public struct AboutAppView: View { public var body: some View { Form { + ForEach(Array(configuration.sections.enumerated()), id: \.offset) { _, section in + content(for: section) + } + } + .navigationTitle(LocalizedStrings.aboutApp) + .sheet(isPresented: $showingMailSheet) { + MailView(app: configuration.app, debugDetails: AboutKit.debugDetails) + .edgesIgnoringSafeArea(.all) + } + } + + + // MARK: - Sections + + @ViewBuilder + private func content(for section: AKSection) -> some View { + switch section { + case .header: Section { HeaderView(app: configuration.app) } + case .contact: if configuration.app.email != nil || configuration.app.websiteURL != nil { Section { Button(action: sendMail) { @@ -39,7 +58,7 @@ public struct AboutAppView: View { systemImage: "envelope" ) } - + if let websiteURL = configuration.app.websiteURL { Button { openURL(websiteURL) @@ -53,6 +72,7 @@ public struct AboutAppView: View { } } + case .developerProfiles: if configuration.app.developer.profiles.isEmpty == false { Section { ForEach( @@ -71,6 +91,7 @@ public struct AboutAppView: View { } } + case .appProfiles: if configuration.app.profiles.isEmpty == false { Section { ForEach( @@ -88,7 +109,8 @@ public struct AboutAppView: View { } } } - + + case .sharingAndReview: if configuration.showShareApp.isVisible || configuration.showWriteReview.isVisible { Section { if configuration.showShareApp.isVisible { @@ -116,6 +138,7 @@ public struct AboutAppView: View { } } + case .legalAndAcknowledgements: if configuration.app.privacyPolicyURL != nil || configuration.app.termsOfUseURL != nil || configuration.app.acknowledgements?.frameworks?.isEmpty == false || configuration.app.acknowledgements?.people?.isEmpty == false { Section { if let privacyPolicyURL = configuration.app.privacyPolicyURL { @@ -155,6 +178,7 @@ public struct AboutAppView: View { } } + case .testFlight: if let testFlightURL = configuration.app.testFlightURL { Section { Button { @@ -168,6 +192,7 @@ public struct AboutAppView: View { } } + case .otherApps: if configuration.otherApps.isEmpty == false { Section { ForEach(configuration.otherApps, content: OtherAppRowView.init) @@ -177,16 +202,24 @@ public struct AboutAppView: View { } label: { Text(LocalizedStrings.viewAllApps) } - + } header: { Text(LocalizedStrings.otherApps) } } - } - .navigationTitle(LocalizedStrings.aboutApp) - .sheet(isPresented: $showingMailSheet) { - MailView(app: configuration.app, debugDetails: AboutKit.debugDetails) - .edgesIgnoringSafeArea(.all) + + case .custom(let title, let entries): + if entries.isEmpty == false { + Section { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + AKEntryRow(entry) + } + } header: { + if let title { + Text(title) + } + } + } } } diff --git a/Sources/AboutKit/MacAboutAppView.swift b/Sources/AboutKit/MacAboutAppView.swift index 86074d0..b071268 100644 --- a/Sources/AboutKit/MacAboutAppView.swift +++ b/Sources/AboutKit/MacAboutAppView.swift @@ -26,11 +26,32 @@ public struct AboutAppView: View { public var body: some View { Form { + ForEach(Array(configuration.sections.enumerated()), id: \.offset) { _, section in + content(for: section) + } + } + .formStyle(.grouped) + .navigationTitle(LocalizedStrings.aboutApp) + .sheet(isPresented: $showingAcknowledgements) { + if let acknowledgements = configuration.app.acknowledgements { + AcknowledgementsView(acknowledgements) + } + } + } + + + // MARK: - Sections + + @ViewBuilder + private func content(for section: AKSection) -> some View { + switch section { + case .header: Section { HeaderView(app: configuration.app) .padding(.vertical, 8) } + case .contact: if configuration.app.email != nil || configuration.app.websiteURL != nil { Section { ItemLabel( @@ -38,7 +59,7 @@ public struct AboutAppView: View { actionTitle: LocalizedStrings.contactDeveloper, action: sendMail ) - + if let websiteURL = configuration.app.websiteURL { ItemLabel( LocalizedStrings.website, @@ -50,6 +71,7 @@ public struct AboutAppView: View { } } + case .developerProfiles: if configuration.app.developer.profiles.isEmpty == false { Section { ForEach( @@ -66,6 +88,7 @@ public struct AboutAppView: View { } } + case .appProfiles: if configuration.app.profiles.isEmpty == false { Section { ForEach( @@ -82,6 +105,7 @@ public struct AboutAppView: View { } } + case .sharingAndReview: if configuration.showShareApp.isVisible || configuration.showWriteReview.isVisible { Section { if configuration.showShareApp.isVisible { @@ -109,6 +133,7 @@ public struct AboutAppView: View { } } + case .legalAndAcknowledgements: if configuration.app.privacyPolicyURL != nil || configuration.app.termsOfUseURL != nil || configuration.app.acknowledgements?.frameworks?.isEmpty == false || configuration.app.acknowledgements?.people?.isEmpty == false { Section { if let privacyPolicyURL = configuration.app.privacyPolicyURL { @@ -142,6 +167,7 @@ public struct AboutAppView: View { } } + case .testFlight: if let testFlightURL = configuration.app.testFlightURL { Section { ItemLabel( @@ -153,6 +179,7 @@ public struct AboutAppView: View { } } + case .otherApps: if configuration.otherApps.isEmpty == false { Section { ForEach(configuration.otherApps, content: OtherAppRowView.init) @@ -168,12 +195,18 @@ public struct AboutAppView: View { Text(LocalizedStrings.otherApps) } } - } - .formStyle(.grouped) - .navigationTitle(LocalizedStrings.aboutApp) - .sheet(isPresented: $showingAcknowledgements) { - if let acknowledgements = configuration.app.acknowledgements { - AcknowledgementsView(acknowledgements) + + case .custom(let title, let entries): + if entries.isEmpty == false { + Section { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + AKEntryRow(entry) + } + } header: { + if let title { + Text(title) + } + } } } } diff --git a/Sources/AboutKit/Models/AKConfiguration.swift b/Sources/AboutKit/Models/AKConfiguration.swift index 5d83c7f..7fd53b2 100644 --- a/Sources/AboutKit/Models/AKConfiguration.swift +++ b/Sources/AboutKit/Models/AKConfiguration.swift @@ -22,6 +22,9 @@ public struct AKConfiguration { /// Indicates whether to show the Write Review option. public let showWriteReview: AKShowOption + /// The ordered sections displayed in the AboutKit form. + public let sections: [AKSection] + /// Initializes a new `AKConfiguration` struct which contains details about the /// current app and other apps. /// - Parameters: @@ -29,16 +32,19 @@ public struct AKConfiguration { /// - otherApps: An array of `AKOtherApp` that contains details about other apps the developer owns. /// - showShareApp: Indicates whether to show the Share App option. Defaults to `.always`. /// - showWriteReview: Indicates whether to show the Write Review option. Defaults to `.always`. + /// - sections: The ordered sections displayed in the AboutKit form. Defaults to `AKSection.defaultOrder`. public init( app: AKMyApp, otherApps: [AKOtherApp], showShareApp: AKShowOption = .always, - showWriteReview: AKShowOption = .always + showWriteReview: AKShowOption = .always, + sections: [AKSection] = AKSection.defaultOrder ) { self.app = app self.otherApps = otherApps self.showShareApp = showShareApp self.showWriteReview = showWriteReview + self.sections = sections } /// An example `AKConfiguration` to be used in SwiftUI previews. diff --git a/Sources/AboutKit/Models/AKEntry.swift b/Sources/AboutKit/Models/AKEntry.swift new file mode 100644 index 0000000..5d5d155 --- /dev/null +++ b/Sources/AboutKit/Models/AKEntry.swift @@ -0,0 +1,51 @@ +// +// AKEntry.swift +// AboutKit +// + +import SwiftUI + +/// A custom row that presents a SwiftUI view from an AboutKit section. +public enum AKEntry { + /// A row that pushes its destination onto the current navigation stack. + case navigation( + title: String, + systemImage: String, + destination: AnyView + ) + + /// A row that presents its destination in a sheet. + case sheet( + title: String, + systemImage: String, + destination: AnyView + ) + + /// Creates a row that pushes its destination onto the current navigation stack. + @MainActor + public static func navigation( + _ title: String, + systemImage: String, + @ViewBuilder destination: () -> Destination + ) -> AKEntry { + .navigation( + title: title, + systemImage: systemImage, + destination: AnyView(destination()) + ) + } + + /// Creates a row that presents its destination in a sheet. + @MainActor + public static func sheet( + _ title: String, + systemImage: String, + @ViewBuilder destination: () -> Destination + ) -> AKEntry { + .sheet( + title: title, + systemImage: systemImage, + destination: AnyView(destination()) + ) + } +} diff --git a/Sources/AboutKit/Models/AKSection.swift b/Sources/AboutKit/Models/AKSection.swift new file mode 100644 index 0000000..c0ca323 --- /dev/null +++ b/Sources/AboutKit/Models/AKSection.swift @@ -0,0 +1,31 @@ +// +// AKSection.swift +// AboutKit +// + +/// A section displayed in the AboutKit form. +public enum AKSection { + case header + case contact + case developerProfiles + case appProfiles + case sharingAndReview + case legalAndAcknowledgements + case testFlight + case otherApps + case custom(title: String?, entries: [AKEntry]) + + /// The section order used when no custom order is supplied. + public static var defaultOrder: [AKSection] { + [ + .header, + .contact, + .developerProfiles, + .appProfiles, + .sharingAndReview, + .legalAndAcknowledgements, + .testFlight, + .otherApps + ] + } +} diff --git a/Sources/AboutKit/Views/AKEntryRow.swift b/Sources/AboutKit/Views/AKEntryRow.swift new file mode 100644 index 0000000..1ca8742 --- /dev/null +++ b/Sources/AboutKit/Views/AKEntryRow.swift @@ -0,0 +1,62 @@ +// +// AKEntryRow.swift +// AboutKit +// + +import SwiftUI + +struct AKEntryRow: View { + private let entry: AKEntry + + @State private var showingSheet = false + + init(_ entry: AKEntry) { + self.entry = entry + } + + var body: some View { + switch entry { + case .navigation(let title, let systemImage, let destination): + NavigationLink { + destination + } label: { + label(title, systemImage: systemImage) + } + + case .sheet(let title, let systemImage, let destination): + Button { + showingSheet = true + } label: { + label(title, systemImage: systemImage) + } + .sheet(isPresented: $showingSheet) { + destination + } + } + } + + @ViewBuilder + private func label(_ title: String, systemImage: String) -> some View { + #if os(iOS) || os(visionOS) + ItemLabel(title, systemImage: systemImage) + #else + Label(title, systemImage: systemImage) + #endif + } +} + +#Preview { + NavigationStack { + Form { + Section("Custom") { + AKEntryRow(.navigation("Navigation", systemImage: "arrow.right") { + Text("Navigation destination") + }) + + AKEntryRow(.sheet("Sheet", systemImage: "rectangle.portrait") { + Text("Sheet destination") + }) + } + } + } +} diff --git a/Sources/AboutKit/WatchTVAboutAppView.swift b/Sources/AboutKit/WatchTVAboutAppView.swift index c0ec097..0ed69df 100644 --- a/Sources/AboutKit/WatchTVAboutAppView.swift +++ b/Sources/AboutKit/WatchTVAboutAppView.swift @@ -22,11 +22,26 @@ public struct AboutAppView: View { public var body: some View { Form { + ForEach(Array(configuration.sections.enumerated()), id: \.offset) { _, section in + content(for: section) + } + } + .navigationTitle(LocalizedStrings.aboutApp) + } + + + // MARK: - Sections + + @ViewBuilder + private func content(for section: AKSection) -> some View { + switch section { + case .header: Section { HeaderView(app: configuration.app) .focusable() } + case .contact: if configuration.app.email != nil || configuration.app.websiteURL != nil { Section { if let email = configuration.app.email { @@ -45,6 +60,7 @@ public struct AboutAppView: View { } } + case .developerProfiles: if configuration.app.developer.profiles.isEmpty == false { Section { ForEach( @@ -59,6 +75,7 @@ public struct AboutAppView: View { } } + case .appProfiles: if configuration.app.profiles.isEmpty == false { Section { ForEach( @@ -72,7 +89,11 @@ public struct AboutAppView: View { } } } - + + case .sharingAndReview: + EmptyView() + + case .legalAndAcknowledgements: if configuration.app.privacyPolicyURL != nil || configuration.app.termsOfUseURL != nil || configuration.app.acknowledgements?.frameworks?.isEmpty == false || configuration.app.acknowledgements?.people?.isEmpty == false { Section { if let privacyPolicyURL = configuration.app.privacyPolicyURL { @@ -99,6 +120,7 @@ public struct AboutAppView: View { } } + case .testFlight: if let testFlightURL = configuration.app.testFlightURL { Section { ItemLabel( @@ -108,6 +130,7 @@ public struct AboutAppView: View { } } + case .otherApps: if configuration.otherApps.isEmpty == false { Section { ForEach( @@ -127,8 +150,24 @@ public struct AboutAppView: View { #endif } } + + case .custom(let title, let entries): + if entries.isEmpty == false { + Section { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + AKEntryRow(entry) + } + } header: { + if let title { + #if os(tvOS) + SectionHeaderLabel(title) + #else + Text(title) + #endif + } + } + } } - .navigationTitle(LocalizedStrings.aboutApp) } } diff --git a/Tests/AboutKitTests/AboutKitTests.swift b/Tests/AboutKitTests/AboutKitTests.swift index c651eb1..3595d9d 100644 --- a/Tests/AboutKitTests/AboutKitTests.swift +++ b/Tests/AboutKitTests/AboutKitTests.swift @@ -1,14 +1,88 @@ import XCTest @testable import AboutKit +import SwiftUI final class AboutKitTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. + func testDefaultSectionOrder() { + let sectionNames = AKSection.defaultOrder.map { section in + switch section { + case .header: "header" + case .contact: "contact" + case .developerProfiles: "developerProfiles" + case .appProfiles: "appProfiles" + case .sharingAndReview: "sharingAndReview" + case .legalAndAcknowledgements: "legalAndAcknowledgements" + case .testFlight: "testFlight" + case .otherApps: "otherApps" + case .custom: "custom" + } + } + + XCTAssertEqual( + sectionNames, + [ + "header", + "contact", + "developerProfiles", + "appProfiles", + "sharingAndReview", + "legalAndAcknowledgements", + "testFlight", + "otherApps" + ] + ) + + MainActor.assumeIsolated { + XCTAssertEqual(AKConfiguration.example.sections.count, sectionNames.count) + } + } + + func testCustomSectionEntries() { + MainActor.assumeIsolated { + let navigationEntry = AKEntry.navigation( + "Purchases", + systemImage: "cart" + ) { + Text("Purchases") + } + + let sheetEntry = AKEntry.sheet( + "Tip Jar", + systemImage: "heart" + ) { + Text("Tip Jar") + } + + let section = AKSection.custom( + title: "Support", + entries: [navigationEntry, sheetEntry] + ) + + guard case .custom(let title, let entries) = section else { + return XCTFail("Expected a custom section") + } + + XCTAssertEqual(title, "Support") + XCTAssertEqual(entries.count, 2) + + guard case .navigation(let title, let systemImage, _) = entries[0] else { + return XCTFail("Expected a navigation entry") + } + + XCTAssertEqual(title, "Purchases") + XCTAssertEqual(systemImage, "cart") + + guard case .sheet(let title, let systemImage, _) = entries[1] else { + return XCTFail("Expected a sheet entry") + } + + XCTAssertEqual(title, "Tip Jar") + XCTAssertEqual(systemImage, "heart") + } } - static var allTests = [ - ("testExample", testExample), + static let allTests = [ + ("testDefaultSectionOrder", testDefaultSectionOrder), + ("testCustomSectionEntries", testCustomSectionEntries), ] } From c9b3197847efff3070f420ef0c809829a78b8e32 Mon Sep 17 00:00:00 2001 From: Oliver Michalak Date: Sat, 22 Aug 2026 12:20:57 +0200 Subject: [PATCH 4/8] tune custom entry signature --- README.md | 4 +++- Sources/AboutKit/Models/AKSection.swift | 2 +- Tests/AboutKitTests/AboutKitTests.swift | 11 ++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bf6309d..4885f27 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ let sections: [AKSection] = [ .contact, .custom( title: "Purchases", - entries: [purchaseEntry, tipJarEntry] + [purchaseEntry, tipJarEntry] ), .developerProfiles, .appProfiles, @@ -103,6 +103,8 @@ let sections: [AKSection] = [ ] ``` +Omit `title` for an untitled custom section: `.custom([purchaseEntry])`. + Pass the order to `AKConfiguration` using its `sections` parameter. ### AKEntry diff --git a/Sources/AboutKit/Models/AKSection.swift b/Sources/AboutKit/Models/AKSection.swift index c0ca323..6701eef 100644 --- a/Sources/AboutKit/Models/AKSection.swift +++ b/Sources/AboutKit/Models/AKSection.swift @@ -13,7 +13,7 @@ public enum AKSection { case legalAndAcknowledgements case testFlight case otherApps - case custom(title: String?, entries: [AKEntry]) + case custom(title: String? = nil, _ entries: [AKEntry]) /// The section order used when no custom order is supplied. public static var defaultOrder: [AKSection] { diff --git a/Tests/AboutKitTests/AboutKitTests.swift b/Tests/AboutKitTests/AboutKitTests.swift index 3595d9d..8efa76b 100644 --- a/Tests/AboutKitTests/AboutKitTests.swift +++ b/Tests/AboutKitTests/AboutKitTests.swift @@ -55,7 +55,7 @@ final class AboutKitTests: XCTestCase { let section = AKSection.custom( title: "Support", - entries: [navigationEntry, sheetEntry] + [navigationEntry, sheetEntry] ) guard case .custom(let title, let entries) = section else { @@ -78,6 +78,15 @@ final class AboutKitTests: XCTestCase { XCTAssertEqual(title, "Tip Jar") XCTAssertEqual(systemImage, "heart") + + let untitledSection = AKSection.custom([navigationEntry]) + + guard case .custom(let title, let entries) = untitledSection else { + return XCTFail("Expected an untitled custom section") + } + + XCTAssertNil(title) + XCTAssertEqual(entries.count, 1) } } From 9dcd908ce3e5f4eb86586314d9c360a343813860 Mon Sep 17 00:00:00 2001 From: Oliver Michalak Date: Sat, 22 Aug 2026 12:32:13 +0200 Subject: [PATCH 5/8] unfold AKEntryRow --- Sources/AboutKit/AboutAppView.swift | 2 +- Sources/AboutKit/MacAboutAppView.swift | 2 +- Sources/AboutKit/Models/AKEntry.swift | 69 +++++++++++++++++++++- Sources/AboutKit/Views/AKEntryRow.swift | 62 ------------------- Sources/AboutKit/WatchTVAboutAppView.swift | 2 +- 5 files changed, 71 insertions(+), 66 deletions(-) delete mode 100644 Sources/AboutKit/Views/AKEntryRow.swift diff --git a/Sources/AboutKit/AboutAppView.swift b/Sources/AboutKit/AboutAppView.swift index cc68594..43e1533 100644 --- a/Sources/AboutKit/AboutAppView.swift +++ b/Sources/AboutKit/AboutAppView.swift @@ -212,7 +212,7 @@ public struct AboutAppView: View { if entries.isEmpty == false { Section { ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in - AKEntryRow(entry) + entry } } header: { if let title { diff --git a/Sources/AboutKit/MacAboutAppView.swift b/Sources/AboutKit/MacAboutAppView.swift index b071268..064f354 100644 --- a/Sources/AboutKit/MacAboutAppView.swift +++ b/Sources/AboutKit/MacAboutAppView.swift @@ -200,7 +200,7 @@ public struct AboutAppView: View { if entries.isEmpty == false { Section { ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in - AKEntryRow(entry) + entry } } header: { if let title { diff --git a/Sources/AboutKit/Models/AKEntry.swift b/Sources/AboutKit/Models/AKEntry.swift index 5d5d155..77a68de 100644 --- a/Sources/AboutKit/Models/AKEntry.swift +++ b/Sources/AboutKit/Models/AKEntry.swift @@ -6,7 +6,7 @@ import SwiftUI /// A custom row that presents a SwiftUI view from an AboutKit section. -public enum AKEntry { +public enum AKEntry: View { /// A row that pushes its destination onto the current navigation stack. case navigation( title: String, @@ -48,4 +48,71 @@ public enum AKEntry { destination: AnyView(destination()) ) } + + @ViewBuilder + public var body: some View { + switch self { + case .navigation(let title, let systemImage, let destination): + NavigationLink { + destination + } label: { + AKEntryLabel(title: title, systemImage: systemImage) + } + + case .sheet(let title, let systemImage, let destination): + AKSheetEntry( + title: title, + systemImage: systemImage, + destination: destination + ) + } + } +} + +private struct AKSheetEntry: View { + let title: String + let systemImage: String + let destination: AnyView + + @State private var isPresented = false + + var body: some View { + Button { + isPresented = true + } label: { + AKEntryLabel(title: title, systemImage: systemImage) + } + .sheet(isPresented: $isPresented) { + destination + } + } +} + +private struct AKEntryLabel: View { + let title: String + let systemImage: String + + var body: some View { + #if os(iOS) || os(visionOS) + ItemLabel(title, systemImage: systemImage) + #else + Label(title, systemImage: systemImage) + #endif + } +} + +#Preview { + NavigationStack { + Form { + Section("Custom") { + AKEntry.navigation("Navigation", systemImage: "arrow.right") { + Text("Navigation destination") + } + + AKEntry.sheet("Sheet", systemImage: "rectangle.portrait") { + Text("Sheet destination") + } + } + } + } } diff --git a/Sources/AboutKit/Views/AKEntryRow.swift b/Sources/AboutKit/Views/AKEntryRow.swift deleted file mode 100644 index 1ca8742..0000000 --- a/Sources/AboutKit/Views/AKEntryRow.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// AKEntryRow.swift -// AboutKit -// - -import SwiftUI - -struct AKEntryRow: View { - private let entry: AKEntry - - @State private var showingSheet = false - - init(_ entry: AKEntry) { - self.entry = entry - } - - var body: some View { - switch entry { - case .navigation(let title, let systemImage, let destination): - NavigationLink { - destination - } label: { - label(title, systemImage: systemImage) - } - - case .sheet(let title, let systemImage, let destination): - Button { - showingSheet = true - } label: { - label(title, systemImage: systemImage) - } - .sheet(isPresented: $showingSheet) { - destination - } - } - } - - @ViewBuilder - private func label(_ title: String, systemImage: String) -> some View { - #if os(iOS) || os(visionOS) - ItemLabel(title, systemImage: systemImage) - #else - Label(title, systemImage: systemImage) - #endif - } -} - -#Preview { - NavigationStack { - Form { - Section("Custom") { - AKEntryRow(.navigation("Navigation", systemImage: "arrow.right") { - Text("Navigation destination") - }) - - AKEntryRow(.sheet("Sheet", systemImage: "rectangle.portrait") { - Text("Sheet destination") - }) - } - } - } -} diff --git a/Sources/AboutKit/WatchTVAboutAppView.swift b/Sources/AboutKit/WatchTVAboutAppView.swift index 0ed69df..defa93a 100644 --- a/Sources/AboutKit/WatchTVAboutAppView.swift +++ b/Sources/AboutKit/WatchTVAboutAppView.swift @@ -155,7 +155,7 @@ public struct AboutAppView: View { if entries.isEmpty == false { Section { ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in - AKEntryRow(entry) + entry } } header: { if let title { From 874725c8fe0ed7003033e41b969bfaf6d343942a Mon Sep 17 00:00:00 2001 From: Oliver Michalak Date: Sat, 22 Aug 2026 12:59:04 +0200 Subject: [PATCH 6/8] adjust macOS presentation style --- Sources/AboutKit/Models/AKEntry.swift | 94 ++++++++++++++++++++----- Tests/AboutKitTests/AboutKitTests.swift | 18 ++--- 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/Sources/AboutKit/Models/AKEntry.swift b/Sources/AboutKit/Models/AKEntry.swift index 77a68de..71f9635 100644 --- a/Sources/AboutKit/Models/AKEntry.swift +++ b/Sources/AboutKit/Models/AKEntry.swift @@ -6,29 +6,54 @@ import SwiftUI /// A custom row that presents a SwiftUI view from an AboutKit section. -public enum AKEntry: View { - /// A row that pushes its destination onto the current navigation stack. - case navigation( - title: String, - systemImage: String, - destination: AnyView - ) +public struct AKEntry: View { + + /// The way an entry presents its destination. + enum Presentation: Equatable { + /// Pushes the destination onto the current navigation stack. + case navigation - /// A row that presents its destination in a sheet. - case sheet( + /// Presents the destination in a sheet. + case sheet + } + + /// The way this entry presents its destination. + let presentation: Presentation + + /// The title displayed in the row. + let title: String + + /// The name of the SF Symbol displayed alongside the title. + let systemImage: String + + /// The type erased destination presented by the row. + private let destination: AnyView + + private init( + presentation: Presentation, title: String, systemImage: String, destination: AnyView - ) + ) { + self.presentation = presentation + self.title = title + self.systemImage = systemImage + self.destination = destination + } /// Creates a row that pushes its destination onto the current navigation stack. + /// - Parameters: + /// - title: The title displayed in the row. + /// - systemImage: The name of the SF Symbol displayed alongside the title. + /// - destination: A view builder producing the view to push. @MainActor public static func navigation( _ title: String, systemImage: String, @ViewBuilder destination: () -> Destination ) -> AKEntry { - .navigation( + AKEntry( + presentation: .navigation, title: title, systemImage: systemImage, destination: AnyView(destination()) @@ -36,13 +61,18 @@ public enum AKEntry: View { } /// Creates a row that presents its destination in a sheet. + /// - Parameters: + /// - title: The title displayed in the row. + /// - systemImage: The name of the SF Symbol displayed alongside the title. + /// - destination: A view builder producing the view to present. @MainActor public static func sheet( _ title: String, systemImage: String, @ViewBuilder destination: () -> Destination ) -> AKEntry { - .sheet( + AKEntry( + presentation: .sheet, title: title, systemImage: systemImage, destination: AnyView(destination()) @@ -51,21 +81,31 @@ public enum AKEntry: View { @ViewBuilder public var body: some View { - switch self { - case .navigation(let title, let systemImage, let destination): + #if os(macOS) + // The macOS form has no navigation stack to push onto, so every entry + // presents its destination in a sheet regardless of its presentation. + AKSheetEntry( + title: title, + systemImage: systemImage, + destination: destination + ) + #else + switch presentation { + case .navigation: NavigationLink { destination } label: { AKEntryLabel(title: title, systemImage: systemImage) } - case .sheet(let title, let systemImage, let destination): + case .sheet: AKSheetEntry( title: title, systemImage: systemImage, destination: destination ) } + #endif } } @@ -77,17 +117,31 @@ private struct AKSheetEntry: View { @State private var isPresented = false var body: some View { + row + .sheet(isPresented: $isPresented) { + destination + } + } + + @ViewBuilder + private var row: some View { + #if os(macOS) + // Matches the built-in macOS rows, which are a title alongside a + // trailing action button and carry no symbol. + ItemLabel(title, actionTitle: LocalizedStrings.viewMac) { + isPresented = true + } + #else Button { isPresented = true } label: { AKEntryLabel(title: title, systemImage: systemImage) } - .sheet(isPresented: $isPresented) { - destination - } + #endif } } +#if !os(macOS) private struct AKEntryLabel: View { let title: String let systemImage: String @@ -100,6 +154,7 @@ private struct AKEntryLabel: View { #endif } } +#endif #Preview { NavigationStack { @@ -114,5 +169,8 @@ private struct AKEntryLabel: View { } } } + #if os(macOS) + .formStyle(.grouped) + #endif } } diff --git a/Tests/AboutKitTests/AboutKitTests.swift b/Tests/AboutKitTests/AboutKitTests.swift index 8efa76b..94a8641 100644 --- a/Tests/AboutKitTests/AboutKitTests.swift +++ b/Tests/AboutKitTests/AboutKitTests.swift @@ -65,19 +65,13 @@ final class AboutKitTests: XCTestCase { XCTAssertEqual(title, "Support") XCTAssertEqual(entries.count, 2) - guard case .navigation(let title, let systemImage, _) = entries[0] else { - return XCTFail("Expected a navigation entry") - } - - XCTAssertEqual(title, "Purchases") - XCTAssertEqual(systemImage, "cart") - - guard case .sheet(let title, let systemImage, _) = entries[1] else { - return XCTFail("Expected a sheet entry") - } + XCTAssertEqual(entries[0].presentation, .navigation) + XCTAssertEqual(entries[0].title, "Purchases") + XCTAssertEqual(entries[0].systemImage, "cart") - XCTAssertEqual(title, "Tip Jar") - XCTAssertEqual(systemImage, "heart") + XCTAssertEqual(entries[1].presentation, .sheet) + XCTAssertEqual(entries[1].title, "Tip Jar") + XCTAssertEqual(entries[1].systemImage, "heart") let untitledSection = AKSection.custom([navigationEntry]) From 885129526c459946ab77826b7e5b713b98662571 Mon Sep 17 00:00:00 2001 From: Oliver Michalak Date: Sat, 22 Aug 2026 13:12:57 +0200 Subject: [PATCH 7/8] update README --- README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4885f27..f9916f1 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ Pass the order to `AKConfiguration` using its `sections` parameter. ### AKEntry -`AKEntry` creates a row with a title and SF Symbol that presents any SwiftUI destination using a navigation push or sheet: +`AKEntry` creates a row with a title and SF Symbol that presents any SwiftUI destination using a navigation push or sheet. Both are available on iOS, watchOS, tvOS and visionOS; see [Platform differences](#platform-differences) for macOS: ```swift let purchaseEntry = AKEntry.navigation( @@ -129,6 +129,33 @@ let tipJarEntry = AKEntry.sheet( Sheet destinations are responsible for providing any navigation container or dismissal controls they require. +#### Platform differences + +On macOS the form has no navigation stack to push onto, so `.navigation` and `.sheet` behave identically: both present their destination in a sheet. Rows also match the surrounding macOS style, showing the title alongside a trailing action button rather than an SF Symbol, so `systemImage` is ignored there. + +This means every destination needs its own dismissal control on macOS, including those created with `.navigation`. Sheets are given a minimum size of 400×300, matching the built-in acknowledgements sheet; a destination that sets its own frame overrides this. + +```swift +struct PurchaseOptionsView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + #if os(macOS) + NavigationStack { + content + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + #else + content + #endif + } +} +``` + ### AKMyApp This is a struct containing details about the current app. It can be created like so: From 5518f7deb2bc6660446382f52944d4be3a6300b5 Mon Sep 17 00:00:00 2001 From: Oliver Michalak Date: Sat, 22 Aug 2026 13:13:08 +0200 Subject: [PATCH 8/8] give macOS sheet a min size --- Sources/AboutKit/Models/AKEntry.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/AboutKit/Models/AKEntry.swift b/Sources/AboutKit/Models/AKEntry.swift index 71f9635..045e23c 100644 --- a/Sources/AboutKit/Models/AKEntry.swift +++ b/Sources/AboutKit/Models/AKEntry.swift @@ -119,7 +119,16 @@ private struct AKSheetEntry: View { var body: some View { row .sheet(isPresented: $isPresented) { + #if os(macOS) + // A macOS sheet is sized by its content, which leaves flexible + // destinations such as forms and lists too small. The floor + // matches the built-in acknowledgements sheet, and a + // destination setting its own frame still wins. destination + .frame(minWidth: 400, minHeight: 300) + #else + destination + #endif } }