diff --git a/BrowserKit/Sources/ComponentLibrary/SwiftUI/ScrollViewCarouselUX.swift b/BrowserKit/Sources/ComponentLibrary/SwiftUI/ScrollViewCarouselUX.swift new file mode 100644 index 0000000000000..51db251cd1afd --- /dev/null +++ b/BrowserKit/Sources/ComponentLibrary/SwiftUI/ScrollViewCarouselUX.swift @@ -0,0 +1,133 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import SwiftUI + +private struct ScrollViewCarouselUX { + static let swipeAnimation: Animation = .interactiveSpring(response: 0.3, dampingFraction: 0.7) + static let stackSpacing: CGFloat = 10 + static let reduceMotionAnimationDuration = 0.3 + static let contentMarginHorizontal: CGFloat = 24 + static let containerFrameSpacing: CGFloat = 0 + static let containerFrameCount = 1 + static let containerFrameSpan = 1 +} + +/// A horizontal scrolling carousel that displays items with smooth scrolling and swipe gestures. +/// Centers the selected item and provides natural navigation between items using ScrollView. +@available(iOS 17.0, *) +public struct ScrollViewCarousel: View { + @Binding public var selection: Int + public let items: [Item] + public let content: (Item) -> Content + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var scrollPosition: Int? + @State private var isInternalUpdate = false + + public init( + selection: Binding, + items: [Item], + @ViewBuilder content: @escaping (Item) -> Content + ) { + self._selection = selection + self.items = items + self.content = content + } + + public var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + scrollViewContent() + } + .scrollPosition(id: $scrollPosition) + .scrollTargetBehavior(.viewAligned) + .contentMargins( + .horizontal, + ScrollViewCarouselUX.contentMarginHorizontal, + for: .scrollContent + ) + .scrollIndicators(.never) + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.allowsDirectInteraction) + .accessibilityAdjustableAction { direction in + handleAccessibilityAdjustment(direction: direction) + } + .onChange(of: scrollPosition, handleScrollPositionChange) + .onChange(of: selection, handleSelectionChange) + .onAppear { + scrollPosition = selection + } + } + + private func scrollViewContent() -> some View { + LazyHStack(spacing: ScrollViewCarouselUX.stackSpacing) { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + content(item) + .containerRelativeFrame( + .horizontal, + count: ScrollViewCarouselUX.containerFrameCount, + span: ScrollViewCarouselUX.containerFrameSpan, + spacing: ScrollViewCarouselUX.containerFrameSpacing + ) + .accessibilityElement(children: .contain) + .accessibilityAddTraits(selection == index ? [.isSelected] : []) + .accessibilityValue("\(index + 1)") + .id(index) + } + } + .scrollTargetLayout() + } + + private func handleScrollPositionChange(_ oldValue: Int?, _ newPosition: Int?) { + guard let newPosition = newPosition, newPosition != selection else { return } + + isInternalUpdate = true + selection = newPosition + provideFeedback() + isInternalUpdate = false + } + + private func handleSelectionChange(_ oldValue: Int, _ newValue: Int) { + guard !isInternalUpdate else { return } + + withAnimation( + reduceMotion ? .easeInOut( + duration: ScrollViewCarouselUX.reduceMotionAnimationDuration + ) : ScrollViewCarouselUX.swipeAnimation + ) { + scrollPosition = newValue + } + provideFeedback() + } + + private func handleAccessibilityAdjustment(direction: AccessibilityAdjustmentDirection) { + let currentIndex = selection + var newIndex: Int? + + switch direction { + case .increment: + if currentIndex < items.count - 1 { + newIndex = currentIndex + 1 + } + case .decrement: + if currentIndex > 0 { + newIndex = currentIndex - 1 + } + @unknown default: + break + } + + if let newIndex = newIndex { + selection = newIndex + } + } + + private func provideFeedback() { + if !reduceMotion { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + // Announce page change for accessibility + UIAccessibility.post(notification: .pageScrolled, argument: nil) + } +} diff --git a/BrowserKit/Sources/OnboardingKit/Views/Compact/OnboardingViewCompact.swift b/BrowserKit/Sources/OnboardingKit/Views/Compact/OnboardingViewCompact.swift index f38ea8e9085e5..9aa3a842e9486 100644 --- a/BrowserKit/Sources/OnboardingKit/Views/Compact/OnboardingViewCompact.swift +++ b/BrowserKit/Sources/OnboardingKit/Views/Compact/OnboardingViewCompact.swift @@ -44,7 +44,13 @@ struct OnboardingViewCompact: View { .accessibilityLabel(viewModel.skipText) .frame(maxWidth: .infinity, alignment: .trailing) - tabView + Group { + if #available(iOS 17.0, *) { + modernScrollViewCarousel + } else { + tabView + } + } Spacer() @@ -75,6 +81,22 @@ struct OnboardingViewCompact: View { } } + @available(iOS 17.0, *) + private var modernScrollViewCarousel: some View { + ScrollViewCarousel( + selection: $viewModel.pageCount, + items: viewModel.onboardingCards + ) { card in + OnboardingCardViewCompact( + viewModel: card, + windowUUID: windowUUID, + themeManager: themeManager, + onBottomButtonAction: viewModel.handleBottomButtonAction, + onMultipleChoiceAction: viewModel.handleMultipleChoiceAction + ) + } + } + private var tabView: some View { TabView(selection: $viewModel.pageCount) { ForEach(Array(viewModel.onboardingCards.enumerated()), id: \.element.name) { index, card in