Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 74 additions & 77 deletions Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Combine
import Common
import Core
#if DEBUG
import Common
import Mocks
#endif
import Models
Expand All @@ -16,19 +16,17 @@ struct ProductDetailsView<ViewModel: ProductDetailsViewModelProtocol>: 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> = [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 {
Expand Down Expand Up @@ -98,7 +96,6 @@ struct ProductDetailsView<ViewModel: ProductDetailsViewModelProtocol>: 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 {
Expand All @@ -108,6 +105,7 @@ struct ProductDetailsView<ViewModel: ProductDetailsViewModelProtocol>: View {
}

@ViewBuilder private var pdpView: some View {
// TODO: Check why iPad uses a different view
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey 👋 @p4checo asked me to take a look and help.
The reason why we had a different view for iPad and iPhone pre iOS 16.4 is:

  • iPhone only supports sheets with detents starting on iOS 16.4
  • iPad does support them but sheets in general on iPad are nothing like the ones on iPhone and so we just decided to not to use it and keep it as a simple ScrollView. But there were no other blockers or particular reasons :)

if isIpad {
legacyPDPView
} else {
Expand Down Expand Up @@ -135,44 +133,67 @@ struct ProductDetailsView<ViewModel: ProductDetailsViewModelProtocol>: 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 {
Expand All @@ -185,25 +206,16 @@ struct ProductDetailsView<ViewModel: ProductDetailsViewModelProtocol>: View {
.fullScreenCover(isPresented: $isMediaFullScreen) {
fullscreenMediaCarousel
}
.sheet(isPresented: $showSizeSheet) {
sizeSheet
}
.sheet(isPresented: $showColorSheet, onDismiss: { colorSheetSearchText = "" }, content: {
colorSheet
})
}

// 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<Bool> {
.init(get: { viewModel.shouldShowLoading(for: section) }, set: { _ in })
}
Expand Down Expand Up @@ -244,30 +256,7 @@ struct ProductDetailsView<ViewModel: ProductDetailsViewModelProtocol>: 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
)
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions Alfie/AlfieKit/Sources/Common/Extensions/Binding+Extension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SwiftUI

public extension Binding where Value == Bool {
var negate: Binding<Value> {
Binding<Value>(
get: { !self.wrappedValue },
set: { self.wrappedValue = !$0 }
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import SwiftUI

@available(iOS 16.4, *)
public struct DraggableBottomSheet<Content: View>: 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<Bool>,
@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
}
}
Loading