diff --git a/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift b/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift index f6050474..2b451b08 100644 --- a/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift +++ b/Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift @@ -1,7 +1,7 @@ import Combine +import Common import Core #if DEBUG -import Common import Mocks #endif import Models @@ -16,19 +16,17 @@ struct ProductDetailsView: View { @State private var isMediaFullScreen = false @State private var showColorSheet = false @State private var showSizeSheet = false - @State private var showDetailsSheet = false @State private var shouldAnimateCurrentMediaIndex = true @State private var carouselSize: CGSize = .zero @State private var viewSize: CGSize = .zero @State private var colorSelectorSize: CGSize = .zero - @State private var bottomSheetCurrentDetent = PresentationDetent.height(0) - // store the detents before navigation to restore afterwards - @State private var bottomSheetDetentBeforeNavigation: PresentationDetent? - @State private var bottomSheetDetents: OrderedSet = [PresentationDetent.height(0)] @State private var currentDescriptionTabIndex = 0 @State private var showFailureState: Bool @State private var hasSpaceForSizeSelector = true @State private var colorSheetSearchText = "" + @State private var bottomButtonsSize: CGSize = .zero + @State private var complementaryViewSize: CGSize = .zero + @State private var shouldCollapseDraggableBottomSheet = false // There are multiple types of color pickers, but they all depend on the same conditions private var canShowColorPickers: Bool { @@ -98,7 +96,6 @@ struct ProductDetailsView: View { if newValue { // give the sheet time to dismiss in case we catch it in the middle of the presentation DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - showDetailsSheet = false showFailureState = true } } else { @@ -108,6 +105,7 @@ struct ProductDetailsView: View { } @ViewBuilder private var pdpView: some View { + // TODO: Check why iPad uses a different view if isIpad { legacyPDPView } else { @@ -135,44 +133,67 @@ struct ProductDetailsView: View { } } + // TODO: Check if we can have the same view for all versions, even if we only drop small features instead of having a completly different view @available(iOS 16.4, *) // swiftlint:disable:next attributes private var iPhonePDPView: some View { - VStack { - mediaCarousel - Spacer() - } - .padding(.horizontal, horizontalPadding) - .task { - showDetailsSheet = true - } - .onAppear { - if let bottomSheetDetentBeforeNavigation { - bottomSheetCurrentDetent = bottomSheetDetentBeforeNavigation - showDetailsSheet = true - } - } - .onChange(of: viewSize) { newValue in - if newValue != .zero { - setupDetents(with: newValue) - } - } - .sheet(isPresented: $showDetailsSheet) { - popupView - .sheet(isPresented: $showColorSheet, onDismiss: { colorSheetSearchText = "" }, content: { - colorSheet - .presentationBackgroundInteraction(.enabled) - }) - .sheet(isPresented: $showSizeSheet) { - sizeSheet - .presentationBackgroundInteraction(.enabled) + GeometryReader { geometry in + let screenHeight = geometry.size.height + let collapsedHeight = screenHeight - carouselSize.height + let shouldHavePinnedButtons = collapsedHeight > 100 + let expandedHeight = shouldHavePinnedButtons ? + min(complementaryViewSize.height + Spacing.space100, screenHeight) : + min(complementaryViewSize.height + bottomButtonsSize.height + Spacing.space100, screenHeight) + + ZStack(alignment: .top) { + VStack { + mediaCarousel + Spacer() + } + .padding(.horizontal, horizontalPadding) + + DraggableBottomSheet( + showCapsule: true, + expandedHeight: expandedHeight, + collapsedHeight: collapsedHeight, + dragStartOffset: carouselSize.height, + expansionSignal: $shouldCollapseDraggableBottomSheet.negate + ) { + complementaryViews + .padding([.horizontal, .top], 16) + .padding(.bottom, shouldHavePinnedButtons ? bottomButtonsSize.height : 0) + .writingSize(to: $complementaryViewSize) + + if !shouldHavePinnedButtons { + bottomButtons + } } - .fullScreenCover(isPresented: $isMediaFullScreen) { - fullscreenMediaCarousel + + if shouldHavePinnedButtons { + bottomButtons } + } + .sheet( + isPresented: $showColorSheet, + onDismiss: { colorSheetSearchText = "" }, + content: { colorSheet.presentationBackgroundInteraction(.enabled) } + ) + .sheet(isPresented: $showSizeSheet) { + sizeSheet.presentationBackgroundInteraction(.enabled) + } + .fullScreenCover(isPresented: $isMediaFullScreen) { + fullscreenMediaCarousel + } + .onChange(of: showColorSheet) { showColorSheet in + shouldCollapseDraggableBottomSheet = showColorSheet || showSizeSheet + } + .onChange(of: showSizeSheet) { showSizeSheet in + shouldCollapseDraggableBottomSheet = showSizeSheet || showColorSheet + } } } + // TODO: Check if can be dropped private var legacyPDPView: some View { VStack { ScrollView { @@ -185,6 +206,9 @@ struct ProductDetailsView: View { .fullScreenCover(isPresented: $isMediaFullScreen) { fullscreenMediaCarousel } + .sheet(isPresented: $showSizeSheet) { + sizeSheet + } .sheet(isPresented: $showColorSheet, onDismiss: { colorSheetSearchText = "" }, content: { colorSheet }) @@ -192,18 +216,6 @@ struct ProductDetailsView: View { // MARK: - Helpers - private func setupDetents(with viewSize: CGSize) { - let collapsedDetent = PresentationDetent.height(viewSize.height + TabBarView.size.height - carouselSize.height) - let expandedDetent = PresentationDetent.height(viewSize.height + TabBarView.size.height) - - bottomSheetDetents = [ - collapsedDetent, - expandedDetent, - ] - - bottomSheetCurrentDetent = collapsedDetent - } - private func shimmeringBinding(for section: ProductDetailsSection) -> Binding { .init(get: { viewModel.shouldShowLoading(for: section) }, set: { _ in }) } @@ -244,30 +256,7 @@ struct ProductDetailsView: View { // MARK: - Sections extension ProductDetailsView { - @available(iOS 16.4, *) - // swiftlint:disable:next attributes - private var popupView: some View { - VStack { - ScrollView(showsIndicators: false) { - complementaryViews - .padding([.horizontal, .top], Spacing.space200) - } - - VStack { - addToBag - addToWishlist - } - .padding(.vertical, Spacing.space100) - .padding(.horizontal, Spacing.space200) - } - .presentationDetents(Set(bottomSheetDetents), selection: $bottomSheetCurrentDetent) - .presentationDragIndicator(.hidden) - .presentationBackgroundInteraction(.enabled) - .interactiveDismissDisabled() - .persistentSystemOverlays(.hidden) - } - - /// contains every view except the media carousel + /// contains every view except the media carousel and bottom buttons private var complementaryViews: some View { VStack(alignment: .leading, spacing: Spacing.space100) { titleHeader @@ -321,6 +310,18 @@ extension ProductDetailsView { .writingSize(to: $carouselSize) } + var bottomButtons: some View { + VStack { + addToBag + addToWishlist + } + .padding(.vertical, Spacing.space100) + .padding(.horizontal, Spacing.space200) + .background(Colors.primary.white) + .writingSize(to: $bottomButtonsSize) + .frame(maxHeight: .infinity, alignment: .bottom) + } + private var fullscreenMediaCarousel: some View { ZoomableCarousel(currentIndex: $currentMediaIndex, configuration: .init(isPresented: $isMediaFullScreen)) { viewModel.productImageUrls.map { url in @@ -556,8 +557,6 @@ extension ProductDetailsView { .modifier( TapHighlightableModifier { guard let feature = viewModel.complementaryInfoWebFeature(for: type) else { return } - showDetailsSheet = false - bottomSheetDetentBeforeNavigation = bottomSheetCurrentDetent coordinator.open(webFeature: feature) } ) @@ -572,10 +571,8 @@ private enum Constants { static let minTitleHeight = 20.0 static let minColorSelectorHeight = 26.0 static let chevronSize: CGFloat = 16 - static let sheetCloseIconSize: CGFloat = 16 static let complementaryInfoCellMinHeight: CGFloat = 72 static let errorViewCircleSize: CGFloat = 210 - static let colorChevronSize: CGFloat = 16 } #if DEBUG diff --git a/Alfie/AlfieKit/Sources/Common/Extensions/Binding+Extension.swift b/Alfie/AlfieKit/Sources/Common/Extensions/Binding+Extension.swift new file mode 100644 index 00000000..b804df5f --- /dev/null +++ b/Alfie/AlfieKit/Sources/Common/Extensions/Binding+Extension.swift @@ -0,0 +1,10 @@ +import SwiftUI + +public extension Binding where Value == Bool { + var negate: Binding { + Binding( + get: { !self.wrappedValue }, + set: { self.wrappedValue = !$0 } + ) + } +} diff --git a/Alfie/AlfieKit/Sources/StyleGuide/Components/DraggableBottomSheet/DraggableBottomSheet.swift b/Alfie/AlfieKit/Sources/StyleGuide/Components/DraggableBottomSheet/DraggableBottomSheet.swift new file mode 100644 index 00000000..ea508a12 --- /dev/null +++ b/Alfie/AlfieKit/Sources/StyleGuide/Components/DraggableBottomSheet/DraggableBottomSheet.swift @@ -0,0 +1,141 @@ +import SwiftUI + +@available(iOS 16.4, *) +public struct DraggableBottomSheet: View { + // MARK: Constants + + private let minVelocity: CGFloat = 800 + + // MARK: State Properties + + @State private var scrollOffset: CGPoint = .zero + @State private var isScrollEnabled = true + @State private var contentViewSize: CGSize = .zero + @State private var dragOffset: CGFloat = 0 + @State private var lastDragOffset: CGFloat = 0 + @State private var isExpanded = false + @Binding private var expansionSignal: Bool + + // MARK: Parameters + + private let showCapsule: Bool + private let expandedHeight: CGFloat + private let collapsedHeight: CGFloat + private let dragStartOffset: CGFloat + private let content: Content + + // MARK: Lifecycle + + public init( + showCapsule: Bool = true, + expandedHeight: CGFloat, + collapsedHeight: CGFloat, + dragStartOffset: CGFloat, + expansionSignal: Binding, + @ViewBuilder content: () -> Content + ) { + self.showCapsule = showCapsule + self.expandedHeight = expandedHeight + self.collapsedHeight = collapsedHeight + self.dragStartOffset = dragStartOffset + self._expansionSignal = expansionSignal + self.content = content() + } + + public var body: some View { + let isScrolledToTop = scrollOffset.y == 0 + + VStack { + if showCapsule { + Capsule() + .frame(width: 40, height: 6) + .foregroundColor(.gray) + .padding(.top, 8) + } + + ScrollViewWithOffsetReader(offset: $scrollOffset) { + content + .writingSize(to: $contentViewSize) + } + .scrollBounceBehavior(.basedOnSize) + .scrollIndicators(.hidden) + .scrollDisabled(!isScrollEnabled) + } + .frame(alignment: .top) + .background(Colors.primary.white) + .clipShape( + .rect( + topLeadingRadius: CornerRadius.s, + bottomLeadingRadius: 0, + bottomTrailingRadius: 0, + topTrailingRadius: CornerRadius.s + ) + ) + .shadow(.softFloat5) + .offset(y: dragStartOffset + dragOffset) + .simultaneousGesture( + DragGesture() + .onChanged { value in + let newOffset = lastDragOffset + value.translation.height + + if isExpanded { + // Only allow dragging if at the top of the scroll view + if newOffset > lastDragOffset && isScrolledToTop { + isScrollEnabled = false + dragOffset = max(min(newOffset, 0), collapsedHeight - expandedHeight) + } else { + isScrollEnabled = true + } + } else { + // Always allow dragging when collapsed + isScrollEnabled = false + dragOffset = max(min(newOffset, 0), collapsedHeight - expandedHeight) + } + } + .onEnded { value in + let velocity = value.velocity.height + let midPoint = (collapsedHeight - expandedHeight) / 2 + + if velocity < -minVelocity { + // Case 1: Fast swipe up → Expand the sheet + expand() + } else if velocity > minVelocity && isScrolledToTop { + // Case 2: Fast swipe down → Collapse the sheet + collapse() + } else if dragOffset < midPoint { + // Case 3: Slow or no swipe, but sheet is past the midpoint → Expand + expand() + } else { + // Case 4: Slow or no swipe, but sheet is before the midpoint → Collapse + collapse() + } + } + ) + .onChange(of: expansionSignal) { shouldExpand in + shouldExpand ? expand() : collapse() + } + .onAppear { + isExpanded ? expand() : collapse() + } + } + + // MARK: Private Methods + + private func expand() { + withAnimation(.spring()) { + dragOffset = collapsedHeight - expandedHeight + isExpanded = true + isScrollEnabled = true + } + lastDragOffset = dragOffset + } + + private func collapse() { + withAnimation(.spring()) { + dragOffset = 0 + isExpanded = false + isScrollEnabled = false + } + lastDragOffset = dragOffset + } +} diff --git a/Alfie/AlfieKit/Sources/StyleGuide/Components/SnapCarousel/SnapCarousel.swift b/Alfie/AlfieKit/Sources/StyleGuide/Components/SnapCarousel/SnapCarousel.swift index 954e2a34..bf78236c 100644 --- a/Alfie/AlfieKit/Sources/StyleGuide/Components/SnapCarousel/SnapCarousel.swift +++ b/Alfie/AlfieKit/Sources/StyleGuide/Components/SnapCarousel/SnapCarousel.swift @@ -50,7 +50,9 @@ public struct SnapCarousel: View { public var body: some View { GeometryReader { proxy in - let sideCutWidth = isSingleItem ? 0 : proxy.size.width / Spacing.space250 + let sideCutWidth = isIpad ? + proxy.size.width / Spacing.space075 : + (isSingleItem ? 0 : proxy.size.width / Spacing.space250) let itemWidth = proxy.size.width - (2 * itemSpacing + 2 * sideCutWidth) let itemHeight = itemWidth / itemAspectRatio // Adjustment that keeps the images centered on each swipe diff --git a/Alfie/AlfieKit/Sources/StyleGuide/Helpers/ScrollViewWithOffsetReader.swift b/Alfie/AlfieKit/Sources/StyleGuide/Helpers/ScrollViewWithOffsetReader.swift index d20f144d..0d718b45 100644 --- a/Alfie/AlfieKit/Sources/StyleGuide/Helpers/ScrollViewWithOffsetReader.swift +++ b/Alfie/AlfieKit/Sources/StyleGuide/Helpers/ScrollViewWithOffsetReader.swift @@ -35,7 +35,7 @@ public struct ScrollViewWithOffsetReader: View { } .coordinateSpace(name: coordinateSpace) .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in - Task.detached { + Task { @MainActor in scrollOffset = .init(x: -value.x, y: -value.y) } }