diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aa67b1a..b8f6d27 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,31 +1,32 @@ name: Build on: - push: + push: branches: - main pull_request: branches: - main jobs: - build-example-project: + test-package: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v3.6.0 - - name: mxcl/Xcodebuild - uses: mxcl/xcodebuild@v2.0.0 + uses: actions/checkout@v7 + - name: Test FlowStack + uses: mxcl/xcodebuild@v3 with: platform: iOS - action: build - workspace: FlowStackExample/FlowStackExample.xcodeproj/project.xcworkspace - scheme: FlowStackExample - build-package: + action: test + scheme: FlowStack + build-example-project: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v3.6.0 - - name: mxcl/Xcodebuild - uses: mxcl/xcodebuild@v2.0.0 + uses: actions/checkout@v7 + - name: Build FlowStackExample + uses: mxcl/xcodebuild@v3 with: platform: iOS - action: build \ No newline at end of file + action: build + workspace: FlowStackExample/FlowStackExample.xcodeproj/project.xcworkspace + scheme: FlowStackExample diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0279df9..4e3cce5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,14 +26,14 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Set up Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v6 - name: Generate Docs run: | xcodebuild docbuild -scheme FlowStack -destination generic/platform=iOS OTHER_DOCC_FLAGS="--transform-for-static-hosting --hosting-base-path FlowStack --output-path docs" - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v5 with: path: ./docs @@ -49,4 +49,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v5 diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..5c90668 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,6 @@ +version: 1 +builder: + configs: + # FlowStack uses UIKit, so its documentation has to be built for iOS rather than the default, macOS. + - documentation_targets: [FlowStack] + platform: ios diff --git a/FlowStackExample/FlowStackExample.xcodeproj/project.pbxproj b/FlowStackExample/FlowStackExample.xcodeproj/project.pbxproj index 7ba7ce0..c7f41db 100644 --- a/FlowStackExample/FlowStackExample.xcodeproj/project.pbxproj +++ b/FlowStackExample/FlowStackExample.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ EDA1DD3A2A67693A00D82A9D /* FlowStackExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA1DD372A67693A00D82A9D /* FlowStackExampleApp.swift */; }; EDA1DD3B2A67693A00D82A9D /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA1DD332A67693A00D82A9D /* Product.swift */; }; EDA1DD3C2A67693A00D82A9D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDA1DD342A67693A00D82A9D /* ContentView.swift */; }; + EDCA5E012E3000000000A001 /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = EDCA5E022E3000000000A002 /* CachedAsyncImage */; }; EDFD07BC2A6F319E00CB0737 /* ProductRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDFD07BB2A6F319E00CB0737 /* ProductRow.swift */; }; /* End PBXBuildFile section */ @@ -38,6 +39,7 @@ buildActionMask = 2147483647; files = ( ED8DE1742A6767B900215165 /* FlowStack in Frameworks */, + EDCA5E012E3000000000A001 /* CachedAsyncImage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -119,6 +121,7 @@ name = FlowStackExample; packageProductDependencies = ( ED8DE1732A6767B900215165 /* FlowStack */, + EDCA5E022E3000000000A002 /* CachedAsyncImage */, ); productName = FlowStackExample; productReference = ED8DE15D2A6765F500215165 /* FlowStackExample.app */; @@ -148,6 +151,9 @@ Base, ); mainGroup = ED8DE1542A6765F500215165; + packageReferences = ( + EDCA5E032E3000000000A003 /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */, + ); productRefGroup = ED8DE15E2A6765F500215165 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -389,11 +395,27 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + EDCA5E032E3000000000A003 /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ ED8DE1732A6767B900215165 /* FlowStack */ = { isa = XCSwiftPackageProductDependency; productName = FlowStack; }; + EDCA5E022E3000000000A002 /* CachedAsyncImage */ = { + isa = XCSwiftPackageProductDependency; + package = EDCA5E032E3000000000A003 /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; + productName = CachedAsyncImage; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = ED8DE1552A6765F500215165 /* Project object */; diff --git a/FlowStackExample/FlowStackExample.xcodeproj/xcshareddata/xcschemes/FlowStackExample.xcscheme b/FlowStackExample/FlowStackExample.xcodeproj/xcshareddata/xcschemes/FlowStackExample.xcscheme new file mode 100644 index 0000000..8ca2333 --- /dev/null +++ b/FlowStackExample/FlowStackExample.xcodeproj/xcshareddata/xcschemes/FlowStackExample.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FlowStackExample/FlowStackExample/ContentView.swift b/FlowStackExample/FlowStackExample/ContentView.swift index 79fa6ed..ba2cf15 100644 --- a/FlowStackExample/FlowStackExample/ContentView.swift +++ b/FlowStackExample/FlowStackExample/ContentView.swift @@ -11,36 +11,145 @@ import FlowStack struct ContentView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass let cornerRadius: CGFloat = 24 + @State private var presentationStyle: FlowPresentationStyle = .automatic + @State private var path = FlowPath() + @StateObject private var detailOptions = DetailOptions() + var body: some View { - FlowStack { - ScrollView { - Group { - if horizontalSizeClass == .compact { - LazyVStack(alignment: .center, spacing: 24, pinnedViews: [], content: { - content - }) - } else { - LazyVGrid(columns: [GridItem(.flexible(), spacing: 16), GridItem(.flexible())], alignment: .center, spacing: 16, content: { - content - }) + FlowStack(path: $path, overlayAlignment: .bottomTrailing) { + // The navigation container goes inside the flow stack. Outside it, the + // navigation bar would be drawn over presented destinations. + NavigationContainer { + ScrollView { + featured + + Group { + if horizontalSizeClass == .compact { + LazyVStack(alignment: .center, spacing: 24, pinnedViews: [], content: { + content + }) + } else { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 16), GridItem(.flexible())], alignment: .center, spacing: 16, content: { + content + }) + } } + .padding(.horizontal) } - .padding(.horizontal) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + presentationStyleMenu + } + } + } + .flowDestination(for: SearchRequest.self) { _ in + ProductSearch() } .flowDestination(for: Product.self) { product in - ProductDetails(product: product) + ProductDetails( + product: product, + usesNavigationStack: detailOptions.usesNavigationStack, + showsSafeArea: detailOptions.showsSafeArea + ) .accessibilityElement(children: .contain) .accessibilityRespondsToUserInteraction(true) .accessibilityLabel("ProductDetails from flowDestination in contentView") } + } overlay: { + // The overlay stays in front of whatever the flow stack presents, so a flow link + // in it can present from anywhere in the stack. This one is only wanted over the + // root, and fades out while anything is presented. + if path.isEmpty { + searchButton + .padding(20) + .transition(.opacity) + } } + .animation(.easeInOut(duration: 0.2), value: path.isEmpty) + // The search brings up the keyboard. Kept out of the flow stack's safe area, it + // doesn't shrink the stack, and the zoom to and from the search button with it; the + // destination keeps clear of the keyboard by itself. + .ignoresSafeArea(.keyboard, edges: .bottom) .accessibilityElement(children: .contain) } + private var searchButton: some View { + FlowLink(value: SearchRequest(), configuration: .init(cornerRadius: 28, presentationStyle: presentationStyle)) { + Image(systemName: "magnifyingglass") + .font(.title2.weight(.semibold)) + .frame(width: 56, height: 56) + .glassCircle() + } + .accessibilityLabel("Search") + } + + private var presentationStyleMenu: some View { + Menu { + Picker("Presentation Style", selection: $presentationStyle) { + ForEach(FlowPresentationStyle.allCases, id: \.self) { style in + Label { + Text(style.title) + Text(style.explanation) + } icon: { + Image(systemName: style.symbolName) + } + .tag(style) + } + } + + Section("Detail") { + Toggle(isOn: $detailOptions.usesNavigationStack) { + Label { + Text("Navigation Stack") + Text("Hosts the close button in a toolbar") + } icon: { + Image(systemName: "menubar.rectangle") + } + } + Toggle(isOn: $detailOptions.showsSafeArea) { + Label { + Text("Show Safe Area") + Text("Turns red if it changes during a pull") + } icon: { + Image(systemName: "ruler") + } + } + } + } label: { + Label("Options", systemImage: "rectangle.on.rectangle.angled") + } + } + + /// Products that are also in the list below, so each is presented by two flow links at + /// once. A destination zooms out of whichever was tapped, and back into the same one. + private var featured: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Featured") + .font(.title2.bold()) + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + // Identified differently to the rows of the list, which FlowStack scrolls + // to by their products' identifiers when it needs one of them created. + ForEach(Product.featuredProducts, id: \.imageUrl) { product in + FlowLink(value: product, configuration: .init(cornerRadius: 16, presentationStyle: presentationStyle)) { + FeaturedCard(product: product, cornerRadius: 16) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Featured product \(product.name)") + } + } + .padding(.horizontal) + } + } + .padding(.bottom, 12) + } + var content: some View { ForEach(Product.allProducts) { product in - FlowLink(value: product, configuration: .init(cornerRadius: cornerRadius)) { + FlowLink(value: product, configuration: .init(cornerRadius: cornerRadius, presentationStyle: presentationStyle)) { ProductRow(product: product, cornerRadius: cornerRadius) .accessibilityElement(children: .combine) } @@ -52,6 +161,69 @@ struct ContentView: View { } } +/// How the detail view is put together. A flow destination is registered once, so its closure +/// reads these through a reference rather than capturing values that would go stale. +private final class DetailOptions: ObservableObject { + @Published var usesNavigationStack = true + @Published var showsSafeArea = false +} + +private extension FlowPresentationStyle { + var title: String { + switch self { + case .automatic: return "Automatic" + case .fullScreen: return "Full Screen" + case .card: return "Card" + } + } + + var explanation: String { + switch self { + case .automatic: return "Full screen on iPhone, card on iPad" + case .fullScreen: return "Always fills the screen" + case .card: return "Floats wherever there's room" + } + } + + var symbolName: String { + switch self { + case .automatic: return "wand.and.stars" + case .fullScreen: return "arrow.up.left.and.arrow.down.right" + case .card: return "rectangle.center.inset.filled" + } + } +} + +private extension View { + /// A circle of glass on systems that have it, and of material before that. + @ViewBuilder + func glassCircle() -> some View { + #if compiler(>=6.2) + if #available(iOS 26.0, *) { + glassEffect(.regular.interactive(), in: .circle) + } else { + background(.regularMaterial, in: Circle()) + } + #else + background(.regularMaterial, in: Circle()) + #endif + } +} + +/// A navigation container, so a view can host toolbar items. +struct NavigationContainer: View { + @ViewBuilder var content: () -> Content + + var body: some View { + if #available(iOS 16.0, *) { + NavigationStack(root: content) + } else { + NavigationView(content: content) + .navigationViewStyle(.stack) + } + } +} + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() diff --git a/FlowStackExample/FlowStackExample/Product.swift b/FlowStackExample/FlowStackExample/Product.swift index 494facf..52685a7 100644 --- a/FlowStackExample/FlowStackExample/Product.swift +++ b/FlowStackExample/FlowStackExample/Product.swift @@ -94,6 +94,10 @@ extension Product { imageUrl: URL(string: "https://user-images.githubusercontent.com/11927517/249293764-c60804a4-8829-44e7-ad58-524a5a8731d4.png")!) } + static var featuredProducts: [Product] { + [.macSE, .appleII, .macColorClassic] + } + static var allProducts: [Product] { [.appleII, .appleIII, .appleLisa, .macSE, .macColorClassic] } diff --git a/FlowStackExample/FlowStackExample/ProductDetails.swift b/FlowStackExample/FlowStackExample/ProductDetails.swift index a3f205e..299f5e4 100644 --- a/FlowStackExample/FlowStackExample/ProductDetails.swift +++ b/FlowStackExample/FlowStackExample/ProductDetails.swift @@ -14,7 +14,67 @@ struct ProductDetails: View { @State var opacity: CGFloat = 0 var product: Product + /// Whether the close button is a toolbar item, which takes a navigation stack to host it, + /// or is placed by hand over the image. + var usesNavigationStack = true + + /// Whether to show the safe area the content is laid out for, to watch it during a pull. + var showsSafeArea = false + var body: some View { + Group { + if usesNavigationStack { + NavigationContainer { + content + // The system positions toolbar items clear of system UI wherever it + // is, e.g. beside iPhone Duo's camera and status items, which sit in + // the trailing corner rather than along the top edge. + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + toolbarCloseButton + .opacity(opacity) + } + } + .hiddenNavigationBarBackground() + } + } else { + content + } + } + .withFlowAnimation { + opacity = 0.78 + } onDismiss: { + opacity = 0 + } + } + + /// The system draws a button with the close role itself, but only in a toolbar. + @ViewBuilder + private var toolbarCloseButton: some View { + if #available(iOS 26.0, *) { + Button(role: .close) { + flowDismiss() + } + } else { + roundCloseButton + } + } + + private var roundCloseButton: some View { + Button(action: { flowDismiss() }, label: { + Image(systemName: "xmark") + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color(uiColor: .darkGray)) + .padding(8) + .background { + Circle() + .foregroundStyle(Color(uiColor: .white)) + } + }) + .accessibilityLabel("Close") + } + + private var content: some View { GeometryReader { proxy in ScrollView { VStack { @@ -26,24 +86,19 @@ struct ProductDetails: View { .fontWeight(.black) .foregroundStyle(.white) .padding() + .padding(.leading, proxy.safeAreaInsets.leading) .opacity(opacity) }) - .overlay(alignment: .topTrailing, content: { - Button(action: { - flowDismiss() - }, label: { - Image(systemName: "xmark") - .foregroundStyle(Color(uiColor: .darkGray)) - .padding(10) - .background { - Circle() - .foregroundStyle(Color(uiColor: .white)) - } - }) - .padding(.horizontal, 12) - .padding(.vertical, proxy.safeAreaInsets.top + 12) - .opacity(opacity) - }) + .overlay(alignment: .topTrailing) { + if !usesNavigationStack { + // Placed by hand, so it has to keep clear of system UI itself, + // on whichever edges that is. + roundCloseButton + .padding(.top, proxy.safeAreaInsets.top + 12) + .padding(.trailing, proxy.safeAreaInsets.trailing + 12) + .opacity(opacity) + } + } .accessibilitySortPriority(100) .clipped() VStack(alignment: .leading, spacing: 40) { @@ -69,17 +124,22 @@ struct ProductDetails: View { .overlay(.quaternary, in: RoundedRectangle(cornerRadius: 24, style: /*@START_MENU_TOKEN@*/.continuous/*@END_MENU_TOKEN@*/).stroke()) } .padding() + .padding(.leading, proxy.safeAreaInsets.leading) + .padding(.trailing, proxy.safeAreaInsets.trailing) .opacity(opacity) } .accessibilityElement(children: .contain) .accessibilityAction(.escape) { flowDismiss() } } - .ignoresSafeArea() - } - .withFlowAnimation { - opacity = 0.78 - } onDismiss: { - opacity = 0 + // Only the container: ignoring the keyboard too would leave any text field in the + // details behind it. + .ignoresSafeArea(.container) + .overlay(alignment: .bottom) { + if showsSafeArea { + SafeAreaReadout(insets: proxy.safeAreaInsets, size: proxy.size) + .padding(.bottom, 12) + } + } } } @@ -115,6 +175,120 @@ struct ProductDetails: View { } } +private extension View { + /// Keeps the navigation bar transparent so the image shows through it. + @ViewBuilder + func hiddenNavigationBarBackground() -> some View { + if #available(iOS 16.0, *) { + toolbarBackground(.hidden, for: .navigationBar) + } else { + self + } + } +} + +/// Shows the safe area a view is laid out for, and turns red while it differs from what it +/// was at rest. A destination's content should keep the same safe area while it is pulled +/// around; when it doesn't, the content reflows under the finger. +private struct SafeAreaReadout: View { + let insets: EdgeInsets + let size: CGSize + + /// The insets once they first held still, after the destination had finished presenting. + @State private var restingInsets: EdgeInsets? + + private var hasChanged: Bool { + guard let resting = restingInsets else { return false } + return abs(resting.top - insets.top) > 0.5 || abs(resting.leading - insets.leading) > 0.5 || + abs(resting.bottom - insets.bottom) > 0.5 || abs(resting.trailing - insets.trailing) > 0.5 + } + + var body: some View { + VStack(spacing: 2) { + Text("safe area \(Self.describe(insets))") + if hasChanged, let resting = restingInsets { + Text("at rest \(Self.describe(resting))") + } + Text("content \(size.width, specifier: "%.0f") × \(size.height, specifier: "%.0f")") + } + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(hasChanged ? Color.red : Color.black.opacity(0.75), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .allowsHitTesting(false) + .task(id: insets) { + // Only the first time: holding a pull still mustn't pass for being at rest. + guard restingInsets == nil else { return } + try? await Task.sleep(nanoseconds: 1_000_000_000) + if !Task.isCancelled, restingInsets == nil { + restingInsets = insets + } + } + } + + private static func describe(_ insets: EdgeInsets) -> String { + String(format: "T %.0f L %.0f B %.0f R %.0f", insets.top, insets.leading, insets.bottom, insets.trailing) + } +} + +/// What the search button presents. There is nothing to it: the search is the destination. +struct SearchRequest: Hashable {} + +/// A search over the products, presented from the button floating over the flow stack. +/// +/// The field takes focus as the search appears, so the keyboard rises with it, and gives +/// it up as the search is dismissed, so the keyboard goes with it. +struct ProductSearch: View { + @Environment(\.flowDismiss) var flowDismiss + @State private var query = "" + @FocusState private var isSearching: Bool + + private var results: [Product] { + let query = query.trimmingCharacters(in: .whitespaces) + return query.isEmpty ? Product.allProducts : Product.allProducts.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + + var body: some View { + NavigationContainer { + List(results) { product in + VStack(alignment: .leading) { + Text(product.name) + Text(product.released) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .safeAreaInset(edge: .bottom) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Products", text: $query) + .focused($isSearching) + .submitLabel(.search) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.thinMaterial, in: Capsule()) + .padding(.horizontal) + .padding(.bottom, 8) + } + .navigationTitle("Search") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if #available(iOS 26.0, *) { + Button(role: .close) { flowDismiss() } + } else { + Button("Done") { flowDismiss() } + } + } + } + } + .onAppear { isSearching = true } + .withFlowAnimation(onDismiss: { isSearching = false }) + } +} + struct ProductDetails_Previews: PreviewProvider { static var previews: some View { ProductDetails(product: .appleII) diff --git a/FlowStackExample/FlowStackExample/ProductRow.swift b/FlowStackExample/FlowStackExample/ProductRow.swift index 7f3c8f5..a1b19a8 100644 --- a/FlowStackExample/FlowStackExample/ProductRow.swift +++ b/FlowStackExample/FlowStackExample/ProductRow.swift @@ -43,6 +43,35 @@ struct ProductRow: View { } } +/// A smaller card for a row of featured products. +struct FeaturedCard: View { + var product: Product + var cornerRadius: CGFloat + + var body: some View { + Color.clear + .frame(width: 168, height: 126) + .overlay { + CachedAsyncImage(url: product.imageUrl, urlCache: .imageCache) { image in + image + .resizable() + .scaledToFill() + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + } placeholder: { + Color(uiColor: .secondarySystemFill) + } + .allowsHitTesting(false) + } + .overlay(alignment: .bottomLeading) { + Text(product.name) + .font(.headline) + .foregroundStyle(.white) + .padding(10) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } +} + struct ProductRow_Previews: PreviewProvider { static var previews: some View { ScrollView { diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 3272b8d..0000000 --- a/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "swiftui-cached-async-image", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image.git", - "state" : { - "revision" : "467a3d17479887943ab917a379e62bbaff60ac8a", - "version" : "2.1.1" - } - } - ], - "version" : 2 -} diff --git a/Package.swift b/Package.swift index 627a292..fc2c9a7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,21 +7,14 @@ let package = Package( name: "FlowStack", platforms: [.iOS(.v15)], products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "FlowStack", targets: ["FlowStack"]), ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/lorenzofiamingo/swiftui-cached-async-image.git", from: "2.1.0"), - ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "FlowStack", - dependencies: [.product(name: "CachedAsyncImage", package: "swiftui-cached-async-image")]), + resources: [.copy("PrivacyInfo.xcprivacy")]), .testTarget( name: "FlowStackTests", dependencies: ["FlowStack"]), diff --git a/README.md b/README.md index 2a5e681..f4d426c 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,23 @@ -**FlowStack** is a SwiftUI library for creating stack-based navigation with "flow" (aka "zooming") transition animations and interactive pull-to-dismiss gestures. FlowStack's API is modeled after Apple's [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack), and though no prior experience with NavigationStack is required to get started with FlowStack, the similarities in use make it easy and intuitive to add FlowStack to a new project or migrate an existing project currently using NavigationStack. An added bonus is that FlowStack is compatible with iOS 15+ where NavigationStack is only available for iOS 16+. +**FlowStack** is a SwiftUI library for creating stack-based navigation with "flow" (aka "zooming") transition animations and interactive pull-to-dismiss gestures. FlowStack's API is modeled after Apple's [NavigationStack](https://developer.apple.com/documentation/swiftui/navigationstack), and though no prior experience with NavigationStack is required to get started with FlowStack, the similarities in use make it easy and intuitive to add FlowStack to a new project or migrate an existing project currently using NavigationStack. An added bonus is that FlowStack is compatible with iOS 15+ where NavigationStack is only available for iOS 16+. (iOS 18 introduced a built-in zoom transition via `navigationTransition(.zoom)`; FlowStack provides similar transitions back to iOS 15, along with interactive pull-to-dismiss and per-link transition customization.) -[![License](https://img.shields.io/badge/License-MIT-black.svg)](https://github.com/velos/FlowStack/blob/develop/LICENSE) -![Xcode 15.0+](https://img.shields.io/badge/Xcode-14.0+-blue.svg) -![iOS 17.0+](https://img.shields.io/badge/iOS-15.0+-blue.svg) -![Swift 5.0+](https://img.shields.io/badge/Swift-5.0+-orange.svg) +[![License](https://img.shields.io/badge/License-MIT-black.svg)](https://github.com/velos/FlowStack/blob/main/LICENSE) +![Xcode 15.0+](https://img.shields.io/badge/Xcode-15.0+-blue.svg) +![iOS 15.0+](https://img.shields.io/badge/iOS-15.0+-blue.svg) +![Swift 5.9+](https://img.shields.io/badge/Swift-5.9+-orange.svg) -image +https://github.com/user-attachments/assets/c975bac5-abe7-4283-9d3c-83203cbd37dd ## Installation To integrate using Apple's Swift package manager, add the following as a dependency to your `Package.swift`: +```swift +.package(url: "https://github.com/velos/FlowStack.git", from: "0.1.0") ``` -.package(url: "https://github.com/velos/FlowStack.git", .branch("develop")) -``` + +The full API reference is in the [FlowStack documentation](https://velosmobile.com/FlowStack/documentation/flowstack/). ## Getting started @@ -42,13 +44,13 @@ NavigationStack { 1. Add the root view inside the **FlowStack**. - For scrolling lists, use a ScrollView with a LazyVStack instead of a List for best animation results. -1. Add a **flowDestination(for:destination:)** modifier within the **FlowStack** hierarchy to associate a data type with it's corresponding destination view. +1. Add a **flowDestination(for:destination:)** modifier within the **FlowStack** hierarchy to associate a data type with its corresponding destination view. 1. Initialize a **FlowLink** with... 1. A value of the same data type handled by the corresponding **flowDestination(for:destination:)** modifier. 1. A **FlowLink.Configuration** to customize aspects of the transition. In the below example, a corner radius value is passed in to the configuration to match the corner radius of the ParkRow during transition. 1. A view to serve as the content for the **FlowLink**. A common use case would be for this view to contain an image (or other elements) also present in the destination view. -In this example, similar to the NavigationStack, when a user selects a given flow link, the park value associated with the link is handled by the corresponding flow destination modifier with matching data type which adds the associated destination view to the stack (in this case, ParkDetails) and presents it via a "zooming" transiton animation. Views can be removed from the stack and dismissed programmatically (by calling the **FlowDismiss** action accessible via the Environment) or by the user dragging down to initiate an interactive dismiss gesture. +In this example, similar to the NavigationStack, when a user selects a given flow link, the park value associated with the link is handled by the corresponding flow destination modifier with matching data type which adds the associated destination view to the stack (in this case, ParkDetails) and presents it via a "zooming" transition animation. Views can be removed from the stack and dismissed programmatically (by calling the **flowDismiss** action from the environment) or by the user dragging down to initiate an interactive dismiss gesture. ```swift FlowStack { @@ -86,7 +88,95 @@ Button("Dismiss") { } ``` -## Manage naviagtion state +To keep the user from dragging a view away, while they have unsaved changes for example, add **flowInteractiveDismissDisabled(_:)** to it. That also keeps a view presented as a card from being dismissed by tapping outside it. Calling **flowDismiss** still dismisses it. + +```swift +// Destination View +EditorView(draft: $draft) + .flowInteractiveDismissDisabled(draft.hasChanges) +``` + +## Returning to a link + +A dismissal zooms back into the flow link wherever it is *now*, not where it was when it was activated. If the layout changed while the destination was presented, because the device rotated or an iPhone Duo was opened or folded, the destination returns to the link's new position and size. + +As a destination is dismissed, its link is also scrolled fully into view, without animation, so there is always somewhere visible to return to. That includes bringing it out from under a navigation bar. It never happens while a destination is being presented. + +After a layout change a link can end up far outside the visible area, where a lazy container like `LazyVStack` hasn't created it. FlowStack scrolls to the link's row by identity to bring it into existence, and takes the row's identifier to be the `id` of an `Identifiable` value, or else the value itself: + +```swift +ForEach(parks) { park in // rows identified by park.id ... + FlowLink(value: park) { ... } // ... which FlowStack derives from the link's value +} +``` + +A `ForEach` keyed some other way still works, but a link that isn't on screen can't be scrolled to, and the dismissal zooms to where the link used to be. + +Dismiss with **flowDismiss** where you can, rather than by removing from a `FlowPath` yourself. It brings the link into view *before* the dismissal begins, which removing from the path directly can only do after the fact. + +## Links that present the same value + +More than one flow link can present the same value at once: a product that appears in a row of featured products, say, and again in the list beneath it. A destination zooms out of the link that was activated and back into that same link, and only that link is hidden while the destination is presented. FlowStack works this out for itself. + +It needs help only once *every* link presenting the value has been recreated while the destination was presented, which is what a layout change does (a rotation, an iPhone Duo opening, ...), and what a lazy container does to links scrolled far out of view. Nothing then says which of the new links stands where the activated one did. Give the links different identifiers with **flowLinkID(_:)** and they are told apart however they are recreated: + +```swift +FeaturedProducts(products) // FlowLink(value: product) { ... } + .flowLinkID("featured") +ProductList(products) // FlowLink(value: product) { ... } +``` + +The identifier only has to differ between links that present the same value, so it can be given to a single link or, as here, to a whole section at once. + +To present a value from a particular link yourself, append it to the flow path with that link's identifier: + +```swift +flowPath.append(product, linkID: "featured") +``` + +Without an identifier, a value appended to the flow path zooms out of the most recently created link that presents it, and every link that presents it is hidden in the meantime. + +One thing to watch for: FlowStack brings a link that a lazy container has let go back [by scrolling to its row's identifier](#returning-to-a-link). Two `ForEach`es over the same values in one scroll view give two rows the same identifier, and the scroll view may go to the wrong one. Identify the rows of one of them some other way, as the example app does for its featured products. + +## An overlay over the stack + +A flow stack can take an `overlay`, a view that stays in front of whatever the stack presents. A flow link in it presents from wherever the stack is, at any depth, and the destination zooms out of and back into the link as usual. That suits a floating button for something like search, or a prompt: + +```swift +@State private var path = FlowPath() + +FlowStack(path: $path, overlayAlignment: .bottomTrailing) { + ProductList() + .flowDestination(for: SearchRequest.self) { _ in + ProductSearch() + } +} overlay: { + if path.isEmpty { // only wanted over the root + FlowLink(value: SearchRequest(), configuration: .init(cornerRadius: 28)) { + Image(systemName: "magnifyingglass") + .frame(width: 56, height: 56) + .glassEffect(.regular.interactive(), in: .circle) // iOS 26 and later + } + .padding(20) + .transition(.opacity) + } +} +.animation(.easeInOut(duration: 0.2), value: path.isEmpty) +.ignoresSafeArea(.keyboard, edges: .bottom) +``` + +The link's label can be anything, including Liquid Glass on iOS 26 and later; the example app falls back to a material circle before that. The overlay is laid out within the stack's safe area, so it keeps clear of system UI on every device. The link itself hides while its destination is presented; hiding the whole overlay on `path.isEmpty`, as here, keeps it from floating over other destinations too. + +A destination that brings up the keyboard, like a search, wants two more things. FlowStack keeps the destination itself clear of the keyboard (see [Text fields and the keyboard](#text-fields-and-the-keyboard)), but the root view and the overlay are laid out around it like any other view. `.ignoresSafeArea(.keyboard)` on the flow stack, as above, stops them from moving behind the destination as the keyboard comes and goes. And give the field focus as the destination appears and take it back as it is dismissed, so the keyboard comes and goes with the zoom: + +```swift +@FocusState private var isSearching: Bool +... +.onAppear { isSearching = true } +.withFlowAnimation(onDismiss: { isSearching = false }) +``` + +## Manage navigation state By default, a flow stack manages state for any view contained, added or removed from the stack. If you need direct access and control of the state, you can create a binding to a FlowPath and initialize a flow stack with the flow path binding. @@ -111,7 +201,7 @@ FlowStack(path: $flowPath) { } ``` -As views are added and removed from the stack, the flow path is updated accordingly. This allows for observation of the flow path if needed as well as the ability to programmatically add and remove items and their associated views from the stack. For example, programmatically presenting a new park detail can be done by simply appending a new park to the flow path. +As views are added and removed from the stack, the flow path is updated accordingly. This allows for observation of the flow path if needed as well as the ability to programmatically add and remove items and their associated views from the stack. For example, programmatically presenting a product's details can be done by simply appending the product to the flow path. ```swift func present(product: Product) { @@ -119,11 +209,85 @@ func present(product: Product) { } ``` +Views can also be removed programmatically: `flowPath.removeLast()` dismisses the top view, and `flowPath.removeAll()` pops all presented views to return to the root. + +## Configure a flow link + +A **FlowLink.Configuration** tunes how a link's destination is presented and dismissed. Every option has a default, so pass only the ones you need: + +```swift +FlowLink(value: park, configuration: .init(cornerRadius: 24, shadowRadius: 8, shadowColor: .black.opacity(0.2))) { + ParkRow(park: park) +} +``` + +| Option | Default | Effect | +|---|---|---| +| `cornerRadius`, `cornerStyle` | `0`, `.circular` | The corners of the destination as it zooms. Match the link's own corners. | +| `shadowRadius`, `shadowColor`, `shadowOffset` | none | The shadow of the destination as it zooms. Match the link's own shadow. | +| `zoomStyle` | `.scaleHorizontally` | `.scaleHorizontally` scales the destination's contents with its frame, so text keeps its line breaks. `.resize` resizes the frame and lays the contents out again at each step. | +| `transitionFromSnapshot` | `true` | Starts the zoom on a snapshot of the link that fades into the destination, and ends the dismissal on one, so the transition begins and ends looking just like the link. | +| `retakeSnapshots` | `false` | Retakes the snapshot every time the link is activated. FlowStack already retakes it when the link's size, value, color scheme or text settings change; use this for a label whose content changes in other ways. | +| `animateFromAnchor` | `true` | Zooms out of the link and back into it. When `false`, the destination slides in from the side instead and the link stays where it is. | +| `swipeUpToDismiss` | `false` | Lets the user dismiss the destination by dragging up as well as down. | +| `showsScrim` | `true` | Dims the flow stack behind the destination. Tapping the scrim dismisses the destination. | +| `presentationStyle` | `.automatic` | Whether the destination fills the flow stack or is presented as a card. See [Presentation style](#presentation-style). | + +The duration and bounce of the transitions are set for the whole stack, with **CustomSmoothAnimation**: `FlowStack(customSmoothAnimation: .init(duration: 0.3, bounce: 0.1)) { ... }`. + +A flow link lays a button over its label, which shrinks the label slightly while it is pressed. The button also keeps controls inside the label from being tapped, so if the label has controls of its own, activate the link with a tap gesture instead, which they take precedence over: + +```swift +FlowLink(value: park, activation: .tapGesture) { + ParkRow(park: park, onFavorite: { favorite(park) }) +} +``` + +## Presentation style + +On iPhone, a destination view fills the flow stack. On iPad, when the flow stack is wide enough, the destination is instead presented as a centered card over the dimmed flow stack. Wide iPhone displays, like an unfolded iPhone Duo or a landscape iPhone Pro Max, present full screen. + +To override this for a given link, pass a **FlowPresentationStyle** to its configuration: + +```swift +// Always fill the flow stack, even on iPad. +FlowLink(value: video, configuration: .init(cornerRadius: cornerRadius, presentationStyle: .fullScreen)) { + VideoRow(video: video) +} +``` + +| Style | Behavior | +|---|---| +| `.automatic` | Full screen on iPhone; a card on other devices when one fits. The default. | +| `.fullScreen` | Always fills the flow stack. | +| `.card` | A card whenever the flow stack is wide enough to fit one, on any device. | + +Destinations that extend under system UI should respect the safe area on *every* edge their content touches, not just the top: on iPhone Duo, the camera and status items sit in the trailing corner and are reported as a trailing inset. For controls like a close button, prefer a toolbar item inside a `NavigationStack` in the destination over positioning one by hand; the system places toolbar items clear of system UI on every device. + +## Text fields and the keyboard + +When the keyboard comes up in a destination that fills the flow stack, the destination stays full size behind the keyboard, as any full-screen view does, and its content keeps clear of the keyboard through its safe area. A destination presented as a card moves up out of the keyboard's way instead, like a form sheet. + +So a destination that extends under system UI, like a hero image running up under the status bar, should ignore only the safe area it means to. `.ignoresSafeArea()` ignores the keyboard too, and leaves a text field in the destination behind it: + +```swift +ScrollView { + ParkHeader(park: park) + ParkDescription(park: park) +} +.safeAreaInset(edge: .bottom) { + TextField("Add a note", text: $note) +} +.ignoresSafeArea(.container, edges: .top) // not .ignoresSafeArea(), which puts the field behind the keyboard +``` + +A destination hosted in a `NavigationStack` keeps its content clear of the keyboard either way, since the navigation stack works out its content's safe area for itself. + ## Animation anchors ![flow_animation_anchor_example_1](https://github.com/velos/FlowStack/assets/11927517/fa6b20da-be32-45d4-a00e-b0aba4f0602d) -By defrault, flow transition animations originate from the bounds of the view provided as content to a FlowLink. However, depending on the given UI, it's sometimes preferable for the transition animation to originate from a subview within the FlowLink's content view. +By default, flow transition animations originate from the bounds of the view provided as content to a FlowLink. However, depending on the given UI, it's sometimes preferable for the transition animation to originate from a subview within the FlowLink's content view. A common use case is when a FlowLink's view contains an image along with additional view elements, but you only want the transition animation to emanate from the image, not the entire view containing the other elements. You can achieve this effect by adding a **.flowAnimationAnchor()** modifier to the view you want the transition animation to emanate from. @@ -173,7 +337,11 @@ VStack { ## Image snapshots -When displaying async images within a FlowLink, use [CachedAsyncImage](https://github.com/lorenzofiamingo/swiftui-cached-async-image) (included in the *FlowStack* library) instead of SwiftUI's provided [AsyncImage](https://developer.apple.com/documentation/swiftui/asyncimage). AsyncImage does not cache fetched images and as a result, will not load a previously fetched image fast enough to be included in transition snapshots (i.e. when `transitionFromSnapshot: true` in FlowLink Configuration). +When displaying async images within a FlowLink, use [CachedAsyncImage](https://github.com/lorenzofiamingo/swiftui-cached-async-image) instead of SwiftUI's provided [AsyncImage](https://developer.apple.com/documentation/swiftui/asyncimage). AsyncImage does not cache fetched images and as a result, will not load a previously fetched image fast enough to be included in transition snapshots (i.e. when `transitionFromSnapshot: true` in FlowLink Configuration). FlowStack itself has no dependencies, so add CachedAsyncImage to your project separately: + +```swift +.package(url: "https://github.com/lorenzofiamingo/swiftui-cached-async-image.git", from: "2.1.0") +``` [CachedAsyncImage docs](https://github.com/lorenzofiamingo/swiftui-cached-async-image) has a similar API to AsyncImage with the added ability to specify a cache for caching images. Setting a larger custom cache size is often necessary to get images to actually be cached; images must not be larger than 5% of the disk cache. [See discussion](https://developer.apple.com/documentation/foundation/nsurlsessiondatadelegate/1411612-urlsession#discussion) diff --git a/Sources/FlowStack/CornerRadii.swift b/Sources/FlowStack/CornerRadii.swift new file mode 100644 index 0000000..b5b0d7c --- /dev/null +++ b/Sources/FlowStack/CornerRadii.swift @@ -0,0 +1,95 @@ +// +// CornerRadii.swift +// + +import SwiftUI + +/// Per-corner radii expressed in physical (left/right) terms, since the +/// display corners they are matched against don't flip with layout direction. +struct CornerRadii: Equatable { + var topLeft: CGFloat + var topRight: CGFloat + var bottomLeft: CGFloat + var bottomRight: CGFloat + + static let zero = CornerRadii(uniform: 0) + + init(topLeft: CGFloat, topRight: CGFloat, bottomLeft: CGFloat, bottomRight: CGFloat) { + self.topLeft = topLeft + self.topRight = topRight + self.bottomLeft = bottomLeft + self.bottomRight = bottomRight + } + + init(uniform radius: CGFloat) { + self.init(topLeft: radius, topRight: radius, bottomLeft: radius, bottomRight: radius) + } + + var isUniform: Bool { + topLeft == topRight && topLeft == bottomLeft && topLeft == bottomRight + } + + var maximum: CGFloat { + max(topLeft, topRight, bottomLeft, bottomRight) + } + + func map(_ transform: (CGFloat) -> CGFloat) -> CornerRadii { + CornerRadii( + topLeft: transform(topLeft), + topRight: transform(topRight), + bottomLeft: transform(bottomLeft), + bottomRight: transform(bottomRight) + ) + } + + /// Each corner's radius interpolated from a shared starting `radius` (at + /// `percent` 0) to this value (at `percent` 1). + func interpolated(from radius: CGFloat, percent: CGFloat) -> CornerRadii { + map { max(0, radius + ($0 - radius) * percent) } + } +} + +extension CornerRadii: Animatable { + typealias AnimatableData = AnimatablePair, AnimatablePair> + + var animatableData: AnimatableData { + get { AnimatablePair(AnimatablePair(topLeft, topRight), AnimatablePair(bottomLeft, bottomRight)) } + set { + topLeft = newValue.first.first + topRight = newValue.first.second + bottomLeft = newValue.second.first + bottomRight = newValue.second.second + } + } +} + +/// A rounded rectangle whose corners can each have a different radius. +struct UnevenCornerShape: Shape { + var radii: CornerRadii + var style: RoundedCornerStyle + + var animatableData: CornerRadii.AnimatableData { + get { radii.animatableData } + set { radii.animatableData = newValue } + } + + func path(in rect: CGRect) -> Path { + if radii.isUniform { + return RoundedRectangle(cornerRadius: radii.topLeft, style: style).path(in: rect) + } + + if #available(iOS 16.0, *) { + // Built as a Path rather than an UnevenRoundedRectangle, which + // would mirror the corners in right-to-left layouts. + let cornerRadii = RectangleCornerRadii( + topLeading: radii.topLeft, + bottomLeading: radii.bottomLeft, + bottomTrailing: radii.bottomRight, + topTrailing: radii.topRight + ) + return Path(roundedRect: rect, cornerRadii: cornerRadii, style: style) + } + + return RoundedRectangle(cornerRadius: radii.maximum, style: style).path(in: rect) + } +} diff --git a/Sources/FlowStack/Documentation.docc/FlowStack.md b/Sources/FlowStack/Documentation.docc/FlowStack.md new file mode 100644 index 0000000..fce7f25 --- /dev/null +++ b/Sources/FlowStack/Documentation.docc/FlowStack.md @@ -0,0 +1,64 @@ +# ``FlowStack`` + +Stack-based navigation with zooming transitions and interactive pull-to-dismiss. + +## Overview + +FlowStack presents views by zooming them out of the view that was tapped, and dismisses them by zooming back into it, either programmatically or as the person drags the view away. Its API follows SwiftUI's `NavigationStack`: put the root view in a ``FlowStack``, present values with a ``FlowLink``, and say which view each type of value presents with ``SwiftUICore/View/flowDestination(for:destination:)``. + +```swift +FlowStack { + ScrollView { + LazyVStack { + ForEach(parks) { park in + FlowLink(value: park, configuration: .init(cornerRadius: 24)) { + ParkRow(park: park) + } + } + } + } + .flowDestination(for: Park.self) { park in + ParkDetails(park: park) + } +} +``` + +FlowStack works back to iOS 15. To present from code, or to see what's presented, give the stack a ``FlowPath`` binding. + +## Topics + +### Presenting views + +- ``FlowStack`` +- ``FlowLink`` +- ``SwiftUICore/View/flowDestination(for:destination:)`` +- ``FlowPath`` + +### Customizing the transition + +- ``FlowLink/Configuration`` +- ``FlowLink/ZoomStyle`` +- ``FlowPresentationStyle`` +- ``FlowLink/Activation`` +- ``SwiftUICore/View/flowAnimationAnchor()`` +- ``CustomSmoothAnimation`` +- ``SwiftUICore/Animation/defaultFlow`` + +### Dismissing views + +- ``FlowDismissAction`` +- ``SwiftUICore/EnvironmentValues/flowDismiss`` +- ``SwiftUICore/View/flowInteractiveDismissDisabled(_:)`` + +### Animating with the transition + +- ``SwiftUICore/View/withFlowAnimation(onPresent:onDismiss:)`` +- ``SwiftUICore/EnvironmentValues/flowAnimationDuration`` + +### Telling apart links that present the same value + +- ``SwiftUICore/View/flowLinkID(_:)`` + +### Reading the stack's state + +- ``SwiftUICore/EnvironmentValues/flowPath`` diff --git a/Sources/FlowStack/FlowLink.swift b/Sources/FlowStack/FlowLink.swift index b975fa4..f53adcb 100644 --- a/Sources/FlowStack/FlowLink.swift +++ b/Sources/FlowStack/FlowLink.swift @@ -6,20 +6,6 @@ import SwiftUI -struct FlowLinkButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(x: configuration.isPressed ? 0.97 : 1, y: configuration.isPressed ? 0.97 : 1, anchor: .center) - .animation(.easeInOut, value: configuration.isPressed) - } -} - -extension ButtonStyle where Self == FlowLinkButtonStyle { - static var flowLink: FlowLinkButtonStyle { - FlowLinkButtonStyle() - } -} - struct PathContextKey: PreferenceKey { static var defaultValue: PathContext? @@ -72,26 +58,28 @@ struct GestureContainer: UIViewRepresentable { @Binding var isPressed: Bool var onTap: () -> Void - class Coordinator { - @Binding var isPressed: Bool + // An NSObject, as UIKit takes a control's targets to be. UIControl.allTargets traps on one + // that isn't, which inspection and accessibility tools can hit even though nothing here does. + class Coordinator: NSObject { + var isPressed: Binding var onTap: () -> Void init(isPressed: Binding, onTap: @escaping () -> Void) { - self._isPressed = isPressed + self.isPressed = isPressed self.onTap = onTap } @objc func onTouchUpInside() { - isPressed = false + isPressed.wrappedValue = false onTap() } @objc func onEnter() { - isPressed = true + isPressed.wrappedValue = true } @objc func onExit() { - isPressed = false + isPressed.wrappedValue = false } } @@ -104,6 +92,7 @@ struct GestureContainer: UIViewRepresentable { } func updateUIView(_ uiView: UIViewType, context: Context) { + context.coordinator.isPressed = $isPressed // The coordinator is made once and reused for the life of the // representable, so without this the button keeps calling the very // first onTap it was given — and that closure captured the FlowLink @@ -220,15 +209,17 @@ public struct FlowLink