diff --git a/Demo/Demo/App.Demo.swift b/Demo/Demo/App.Demo.swift index 462f3fc..aa282da 100644 --- a/Demo/Demo/App.Demo.swift +++ b/Demo/Demo/App.Demo.swift @@ -16,7 +16,7 @@ struct DemoApp: App { #if targetEnvironment(macCatalyst) WindowGroup(id: DemoWindow.recap.id, for: DemoWindow.self) { _ in - DemoRecapScreen(isRunningInMacCatalystEnvironment: true) + DemoRecapScreen() } .defaultSize(width: 800.0, height: 800.0) .windowResizability(.contentMinSize) diff --git a/Demo/Demo/ContentView.swift b/Demo/Demo/ContentView.swift index c3b23c3..5eb6136 100644 --- a/Demo/Demo/ContentView.swift +++ b/Demo/Demo/ContentView.swift @@ -2,52 +2,52 @@ import Recap import SwiftUI struct ContentView: View { - @Environment(\.openWindow) private var openWindow - @State private var isPresentingRecapScreen = false - - var body: some View { - VStack { - self.showReleasesButton - } - .task({ - try? await Task.sleep(for: .seconds(0.5)) - self.isPresentingRecapScreen = true - }) - .sheet(isPresented: $isPresentingRecapScreen, content: { - self.recapScreen - }) - } + @Environment(\.isMacCatalystEnvironment) private var isMacCatalystEnvironment + @Environment(\.openWindow) private var openWindow + + @State private var isPresentingRecapScreen = false + var body: some View { + VStack { + self.showReleasesButton + } + .sheet(isPresented: $isPresentingRecapScreen, content: { + self.recapScreen + }) + .task({ + guard !self.isMacCatalystEnvironment else { return } + + try? await Task.sleep(for: .seconds(0.5)) + self.presentRecapScreen() + }) + } +} + +private extension ContentView { + @ViewBuilder var showReleasesButton: some View { - Button(action: { - self.isPresentingRecapScreen = true - }, label: { - Label("Show What's New", systemImage: "sparkles") - }) + Button(action: { + self.presentRecapScreen() + }, label: { + Label("Show What's New", systemImage: "sparkles") + .frame(maxWidth: .infinity) + }) + .padding(.vertical, self.isMacCatalystEnvironment ? 128.0 : 0.0) } - // Optionally you can add a `leadingView` and `trailingView` to your RecapScreen. - // If you do, you may also wish to specify the start index of your RecapScreen - // by using the `.recapScreenStartIndex()` modifier, which takes three parameters: - // .leadingView, .trailingView, and `.release(Int)`, specifying the index of the release you wish to display. var recapScreen: some View { - RecapScreen(releases: .releases) - .recapScreenDismissButtonStyle(Color.pink, Color.white) - .recapScreenIconFillMode(.gradient) - .recapScreenTitleStyle(.foreground) - .recapScreenPageIndicatorColors( - selected: Color.pink, - deselected: Color.gray - ) + DemoRecapScreen() } } -public extension [Release] { - static var releases: [Release] { - ReleasesParser(fileName: "Releases").releases - } -} +// MARK: ContentView -#Preview { - ContentView() +private extension ContentView { + func presentRecapScreen() { + if self.isMacCatalystEnvironment { + self.openWindow(id: DemoWindow.recap.id, value: DemoWindow.recap) + } else { + self.isPresentingRecapScreen = true + } + } } diff --git a/Demo/Demo/DemoRecapScreen.swift b/Demo/Demo/DemoRecapScreen.swift new file mode 100644 index 0000000..9594f56 --- /dev/null +++ b/Demo/Demo/DemoRecapScreen.swift @@ -0,0 +1,31 @@ +import Recap +import SwiftUI + +struct DemoRecapScreen: View { + @Environment(\.isMacCatalystEnvironment) private var isMacCatalystEnvironment + + var body: some View { + // Optionally you can add a `leadingView` and `trailingView` to your RecapScreen. + // If you do, you may also wish to specify the start index of your RecapScreen + // by using the `.recapScreenStartIndex()` modifier, which takes three parameters: + // .leadingView, .trailingView, and `.release(Int)`, specifying the index of the release you wish to display. + RecapScreen(releases: .releases) + .recapScreenDismissButtonStyle(Color.pink, Color.white) + .recapScreenIconFillMode(.gradient) + .recapScreenTitleStyle(.foreground) + .recapScreenPageIndicatorColors( + selected: Color.pink, + deselected: Color.gray + ) + .recapScreenPaginationStyle(self.isMacCatalystEnvironment ? .buttons : .default) + .recapScreenDismissButtonVisibility(self.isMacCatalystEnvironment ? .hidden : .visible) + } +} + +// MARK: [Release] + +private extension [Release] { + static var releases: [Release] { + ReleasesParser(fileName: "Releases").releases + } +} diff --git a/Demo/Demo/Environment+Platform.swift b/Demo/Demo/Environment+Platform.swift new file mode 100644 index 0000000..5e398ce --- /dev/null +++ b/Demo/Demo/Environment+Platform.swift @@ -0,0 +1,11 @@ +import SwiftUI + +extension EnvironmentValues { + var isMacCatalystEnvironment: Bool { + #if targetEnvironment(macCatalyst) + true + #else + false + #endif + } +} diff --git a/Sources/Recap/Public/RecapScreen.swift b/Sources/Recap/Public/RecapScreen.swift index 51302c8..2f7bcd1 100644 --- a/Sources/Recap/Public/RecapScreen.swift +++ b/Sources/Recap/Public/RecapScreen.swift @@ -28,7 +28,9 @@ public struct RecapScreen: View { @Environment(\.recapScreenDeselectedPageIndicatorColor) private var deselectedPageIndicatorColor @Environment(\.recapScreenDismissButtonStyle) private var dismissButtonStyle @Environment(\.recapScreenDismissButtonTitle) private var dismissButtonTitle + @Environment(\.recapScreenDismissButtonVisibility) private var dismissButtonVisibility @Environment(\.recapScreenDismissAction) private var dismissAction + @Environment(\.recapScreenPaginationStyle) private var paginationStyle @State private var originalSelectedPageIndicatorColor: UIColor? @State private var originalDeselectedPageIndicatorColor: UIColor? @@ -50,55 +52,39 @@ public struct RecapScreen: View { self.leadingView .tag(self.tabIndex(from: .leadingView)) - ForEach(self.displayedReleases) { release in + ForEach(Array(self.displayedReleases.enumerated()), id: \.element.id) { index, release in ReleaseView(release: release) .padding(.bottom, 32.0) - .tag((self.tabIndex(from: .release( - self.displayedReleases.firstIndex(of: release) ?? 0) - ))) + .tag(self.releasePageIndex(for: index)) } self.trailingView .tag(self.tabIndex(from: .trailingView)) } - .tabViewStyle(.page(indexDisplayMode: self.hasMultiplePages ? .always : .never)) + .tabViewStyle(.page(indexDisplayMode: self.usesDefaultPaginationControls ? .always : .never)) .background(self.derivedBackgroundStyle) - Button(action: { - dismissAction?() ?? dismiss() - }, label: { - HStack { - Spacer(minLength: 0.0) + if self.showsFooter { + VStack(spacing: 0.0) { + if self.usesButtonPaginationControls { + self.paginationControls + } - Text(self.dismissButtonTitle) - .font(.system(.title3, weight: .bold)) - .padding(8.0) - .padding(.vertical, 4.0) - .padding(.horizontal, 16.0) - .foregroundStyle(dismissButtonStyle.foregroundStyle) - - Spacer(minLength: 0.0) + if self.showsDismissButton { + self.dismissButton + } } - .contentShape(.rect(cornerRadius: 16.0)) - }) - .buttonStyle(.borderless) - .frame(maxWidth: .infinity) - .background(self.dismissButtonStyle.backgroundStyle) - .versionSpecificClipShape() - .padding(.horizontal, 40.0) - .foregroundStyle(.primary) - .withBottomPaddingIfNoSafeArea() - .background(self.derivedBackgroundStyle) - .onAppear(perform: { - self.selectedIndex = self.tabIndex(from: self.startIndex) - }) - .onAppear(perform: { - self.setupAppearanceChanges() - }) - .onDisappear(perform: { - self.teardownAppearanceChanges() - }) + .background(self.derivedBackgroundStyle) + } } + .onAppear(perform: { + self.selectedIndex = self.tabIndex(from: self.startIndex) + + self.setupAppearanceChanges() + }) + .onDisappear(perform: { + self.teardownAppearanceChanges() + }) } } @@ -131,16 +117,127 @@ public extension RecapScreen where LeadingView == EmptyView, TrailingView == Emp // MARK: Private private extension RecapScreen { + var paginationControls: some View { + HStack(spacing: 16.0) { + self.paginationButton( + title: LocalizedStringResource( + "RECAP.SCREEN.PAGINATION.BUTTON.PREVIOUS", + bundle: .atURL(Bundle.module.bundleURL) + ), + systemImage: "arrow.left", + direction: .previous + ) + .frame(maxWidth: .infinity, alignment: .leading) + + self.pageIndicators + + self.paginationButton( + title: LocalizedStringResource( + "RECAP.SCREEN.PAGINATION.BUTTON.NEXT", + bundle: .atURL(Bundle.module.bundleURL) + ), + systemImage: "arrow.right", + direction: .next + ) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.top, 24.0) + .padding(.horizontal, 32.0) + .foregroundStyle(.primary) + .withBottomPaddingIfNoSafeArea(when: !self.showsDismissButton) + } + + var pageIndicators: some View { + HStack(spacing: 10.0) { + ForEach(Array(0.. 1 + self.pageCount > 1 + } + + var usesDefaultPaginationControls: Bool { + self.paginationStyle == .default && self.hasMultiplePages } + var usesButtonPaginationControls: Bool { + self.paginationStyle == .buttons && self.hasMultiplePages + } + + var showsDismissButton: Bool { + self.dismissButtonVisibility == .visible + } + + var showsFooter: Bool { + self.usesButtonPaginationControls || self.showsDismissButton + } + + var leadingPageIndex: Int { + 0 + } + + var trailingPageIndex: Int { + self.pageCount - 1 + } + var derivedBackgroundStyle: AnyShapeStyle { if let backgroundStyle { backgroundStyle @@ -149,6 +246,33 @@ private extension RecapScreen { } } + func canPaginate(in direction: PaginationDirection) -> Bool { + switch direction { + case .previous: self.selectedIndex > 0 + case .next: self.selectedIndex < (self.pageCount - 1) + } + } + + func tabIndex(from startIndex: RecapScreenStartIndex) -> Int { + switch startIndex { + case .leadingView: + return 0 + + case .trailingView: + return max(self.trailingPageIndex, 0) + + case .release(let index): + let clampedIndex = min(max(index, 0), max(self.displayedReleases.count - 1, 0)) + + return self.releasePageIndex(for: clampedIndex) + } + } + + + func releasePageIndex(for releaseIndex: Int) -> Int { + releaseIndex + (self.hasLeadingPage ? 1 : 0) + } + func setupAppearanceChanges() { #if canImport(UIKit) self.originalSelectedPageIndicatorColor = UIPageControl.appearance().currentPageIndicatorTintColor @@ -166,15 +290,52 @@ private extension RecapScreen { #endif } - func tabIndex(from startIndex: RecapScreenStartIndex) -> Int { - switch startIndex { - case .leadingView: 0 - case .trailingView: self.releases.count + 1 - case .release(let index): index + 1 + @ViewBuilder + func paginationButton(title: LocalizedStringResource, systemImage: String, direction: PaginationDirection) -> some View { + let isEnabled = self.canPaginate(in: direction) + + Button(action: { + self.paginate(in: direction) + }, label: { + HStack(spacing: 8.0) { + if direction == .previous { + Image(systemName: systemImage) + } + + Text(title) + .lineLimit(1) + + if direction == .next { + Image(systemName: systemImage) + } + } + .font(.system(.title3, weight: .semibold)) + .contentShape(.rect) + .opacity(isEnabled ? 1.0 : 0.35) + }) + .buttonStyle(.plain) + .disabled(!isEnabled) + } + + func paginate(in direction: PaginationDirection) { + guard self.canPaginate(in: direction) else { return } + + withAnimation { + switch direction { + case .previous: self.selectedIndex -= 1 + case .next: self.selectedIndex += 1 + } } } } +// MARK: PaginationDirection + +private enum PaginationDirection { + case previous + case next +} + // MARK: Safe Area Insets private extension View { @@ -188,9 +349,9 @@ private extension View { } var hasSafeAreaForBottomPadding: Bool { -#if os(macOS) + #if os(macOS) || targetEnvironment(macCatalyst) return false -#else + #else if UIDevice.current.userInterfaceIdiom == .pad { // On iPad, we don't display fullscreen so the home bar isn't relevant. return false @@ -200,12 +361,12 @@ private extension View { let mainWindow = (mainScene?.keyWindow ?? mainScene?.windows.first) return (mainWindow?.safeAreaInsets.bottom ?? 0.0) > 0.0 } -#endif + #endif } @ViewBuilder - func withBottomPaddingIfNoSafeArea() -> some View { - if hasSafeAreaForBottomPadding { + func withBottomPaddingIfNoSafeArea(when shouldApply: Bool = true) -> some View { + if !shouldApply || hasSafeAreaForBottomPadding { self } else { self.padding(.bottom, 24.0)