diff --git a/Demo/Storybook/Storybook.xcodeproj/project.pbxproj b/Demo/Storybook/Storybook.xcodeproj/project.pbxproj index 225a7978..3eb3ab55 100644 --- a/Demo/Storybook/Storybook.xcodeproj/project.pbxproj +++ b/Demo/Storybook/Storybook.xcodeproj/project.pbxproj @@ -7,9 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 2D6768C12C9C45CD007CC431 /* PovioKitSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 2D6768C02C9C45CD007CC431 /* PovioKitSwiftUI */; }; + 2D6768C32C9C4606007CC431 /* PhotoPreviewComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6768C22C9C4606007CC431 /* PhotoPreviewComponentView.swift */; }; 990C176E297E98C2005EC7B7 /* PaddingLabelComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990C176C297E98C2005EC7B7 /* PaddingLabelComponentView.swift */; }; 990C176F297E98C2005EC7B7 /* ActionButtonComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990C176D297E98C2005EC7B7 /* ActionButtonComponentView.swift */; }; - 990C1779297E9D96005EC7B7 /* PovioKitUI in Frameworks */ = {isa = PBXBuildFile; productRef = 990C1778297E9D96005EC7B7 /* PovioKitUI */; }; 998E6EF7297E82F700D33909 /* StorybookApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 998E6EF6297E82F700D33909 /* StorybookApp.swift */; }; 998E6EF9297E82F700D33909 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 998E6EF8297E82F700D33909 /* ContentView.swift */; }; 998E6EFB297E82F800D33909 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 998E6EFA297E82F800D33909 /* Assets.xcassets */; }; @@ -18,6 +19,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 2D6768C22C9C4606007CC431 /* PhotoPreviewComponentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPreviewComponentView.swift; sourceTree = ""; }; 990C176C297E98C2005EC7B7 /* PaddingLabelComponentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaddingLabelComponentView.swift; sourceTree = ""; }; 990C176D297E98C2005EC7B7 /* ActionButtonComponentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButtonComponentView.swift; sourceTree = ""; }; 990C1776297E9D54005EC7B7 /* PovioKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = PovioKit; path = ../..; sourceTree = ""; }; @@ -35,7 +37,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 990C1779297E9D96005EC7B7 /* PovioKitUI in Frameworks */, + 2D6768C12C9C45CD007CC431 /* PovioKitSwiftUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -102,6 +104,7 @@ children = ( 990C176D297E98C2005EC7B7 /* ActionButtonComponentView.swift */, 990C176C297E98C2005EC7B7 /* PaddingLabelComponentView.swift */, + 2D6768C22C9C4606007CC431 /* PhotoPreviewComponentView.swift */, ); path = Components; sourceTree = ""; @@ -123,7 +126,7 @@ ); name = Storybook; packageProductDependencies = ( - 990C1778297E9D96005EC7B7 /* PovioKitUI */, + 2D6768C02C9C45CD007CC431 /* PovioKitSwiftUI */, ); productName = Storybook; productReference = 998E6EF3297E82F700D33909 /* Storybook.app */; @@ -181,6 +184,7 @@ files = ( 998E6EF9297E82F700D33909 /* ContentView.swift in Sources */, 998E6F08297E8AE000D33909 /* Component.swift in Sources */, + 2D6768C32C9C4606007CC431 /* PhotoPreviewComponentView.swift in Sources */, 998E6EF7297E82F700D33909 /* StorybookApp.swift in Sources */, 990C176E297E98C2005EC7B7 /* PaddingLabelComponentView.swift in Sources */, 990C176F297E98C2005EC7B7 /* ActionButtonComponentView.swift in Sources */, @@ -399,9 +403,9 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ - 990C1778297E9D96005EC7B7 /* PovioKitUI */ = { + 2D6768C02C9C45CD007CC431 /* PovioKitSwiftUI */ = { isa = XCSwiftPackageProductDependency; - productName = PovioKitUI; + productName = PovioKitSwiftUI; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Demo/Storybook/Storybook/Component.swift b/Demo/Storybook/Storybook/Component.swift index cc46593d..134064a1 100644 --- a/Demo/Storybook/Storybook/Component.swift +++ b/Demo/Storybook/Storybook/Component.swift @@ -10,6 +10,7 @@ import Foundation enum Component: CaseIterable { case actionButton case paddingLabel + case photoPreview } extension Component { @@ -19,6 +20,8 @@ extension Component { return "Action Button" case .paddingLabel: return "Padding Label" + case.photoPreview: + return "Photo Preview" } } } diff --git a/Demo/Storybook/Storybook/Components/ActionButtonComponentView.swift b/Demo/Storybook/Storybook/Components/ActionButtonComponentView.swift index c0db64b9..2659e9a5 100644 --- a/Demo/Storybook/Storybook/Components/ActionButtonComponentView.swift +++ b/Demo/Storybook/Storybook/Components/ActionButtonComponentView.swift @@ -6,7 +6,7 @@ // import SwiftUI -import PovioKitUI +import PovioKitSwiftUI struct ActionButtonComponentView: View { var body: some View { diff --git a/Demo/Storybook/Storybook/Components/PhotoPreviewComponentView.swift b/Demo/Storybook/Storybook/Components/PhotoPreviewComponentView.swift new file mode 100644 index 00000000..5f23d304 --- /dev/null +++ b/Demo/Storybook/Storybook/Components/PhotoPreviewComponentView.swift @@ -0,0 +1,29 @@ +// +// PhotoPreviewComponentView.swift +// Storybook +// +// Created by Ndriqim Nagavci on 19/09/2024. +// + +import PovioKitSwiftUI +import SwiftUI + +struct PhotoPreviewComponentView: View { + @State private var showPhotoPreview = false + private var items: [PhotoPreview.Item] = [ + .init(image: Image("PovioKit")), + .init(url: .init(string: "https://raw.githubusercontent.com/poviolabs/PovioKit/develop/Resources/PovioKit.png")) + ] + + var body: some View { + Button { + showPhotoPreview.toggle() + } label: { + Text("Show Photo Preview") + } + .photoPreview( + present: $showPhotoPreview, + items: items + ) + } +} diff --git a/Demo/Storybook/Storybook/ContentView.swift b/Demo/Storybook/Storybook/ContentView.swift index f5241750..61269edc 100644 --- a/Demo/Storybook/Storybook/ContentView.swift +++ b/Demo/Storybook/Storybook/ContentView.swift @@ -46,6 +46,10 @@ struct ContentView: View { PaddingLabelComponentView() .navigationTitle(component.name) .navigationBarTitleDisplayMode(.large) + case .photoPreview: + PhotoPreviewComponentView() + .navigationTitle(component.name) + .navigationBarTitleDisplayMode(.large) } } } diff --git a/Resources/UI/SwiftUI/PhotoPreview/README.md b/Resources/UI/SwiftUI/PhotoPreview/README.md new file mode 100644 index 00000000..fe7bb008 --- /dev/null +++ b/Resources/UI/SwiftUI/PhotoPreview/README.md @@ -0,0 +1,28 @@ +# PhotoPreview + +SwiftUI view intended to present an image or set of images in full-screen mode. + +## Usage + +### Example: Implementation in SwiftUI +```swift +struct ContentView: View { + @State private var showPhotoPreview = false + private var items: [PhotoPreviewItem] = [ + .init(image: Image("image1")), + .init(url: .init(string: "remote-url")) + ] + + var body: some View { + Button { + showPhotoPreview.toggle() + } label: { + Text("Show images") + } + .photoPreview( + present: $showPhotoPreview, + items: items + ) + } +} +``` diff --git a/Resources/UI/SwiftUI/README.md b/Resources/UI/SwiftUI/README.md index 552c310d..0f0d537d 100644 --- a/Resources/UI/SwiftUI/README.md +++ b/Resources/UI/SwiftUI/README.md @@ -8,6 +8,7 @@ A package including components to help you out developing for SwiftUI framework. | :--- | :--- | | [ActionButton](ActionButton) | Generic and customizable CTA button | | [PhotoPickerView](/Sources/UI/SwiftUI/Views/PhotoPickerView/PhotoPickerView.swift) | Photo and Camera picker view used in combination with `PhotoPickerModifier` | +| [PhotoPreview](/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview.swift) | Present an image or set of images in full-screen mode. | | [ProfileImageView](ProfileImageView) | Generic and customizable profile image view component | | [ProgressStyle](/Sources/UI/SwiftUI/Views/ProgressStyle/ProgressStyle.swift) | Customizable ProgressViewStyle | | [RemoteImage](/Sources/UI/SwiftUI/Views/RemoteImage/RemoteImage.swift) | Fetching remote images using Kingfisher | diff --git a/Sources/UI/SwiftUI/View Modifiers/PhotoPreviewModifier.swift b/Sources/UI/SwiftUI/View Modifiers/PhotoPreviewModifier.swift new file mode 100644 index 00000000..d9470416 --- /dev/null +++ b/Sources/UI/SwiftUI/View Modifiers/PhotoPreviewModifier.swift @@ -0,0 +1,66 @@ +// +// PhotoPreviewModifier.swift +// PovioKit +// +// Created by Ndriqim Nagavci on 14/08/2024. +// Copyright © 2024 Povio Inc. All rights reserved. +// + +import SwiftUI + +@available(iOS 16.0, *) +struct PhotoPreviewModifier: ViewModifier { + @Binding var presented: Bool + let items: [PhotoPreview.Item] + let configuration: PhotoPreview.Configuration + + public func body(content: Content) -> some View { + content + .fullScreenCover(isPresented: $presented) { + PhotoPreview( + items: items, + configuration: configuration, + presented: $presented + ) + .presentationBackgroundIfAvailable(.black.opacity(0.01)) + } + } +} + +@available(iOS 16.0, *) +public extension View { + func photoPreview( + present: Binding, + items: [PhotoPreview.Item], + configuration: PhotoPreview.Configuration = .default + ) -> some View { + modifier(PhotoPreviewModifier( + presented: present, + items: items, + configuration: configuration + )) + } +} + +@available(iOS 16.0, *) +extension PhotoPreviewModifier { + struct PresentationBackgroundModifier: ViewModifier { + let color: Color + + func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + content + .presentationBackground(color) + } else { + content + } + } + } +} + +@available(iOS 16.0, *) +extension View { + func presentationBackgroundIfAvailable(_ color: Color) -> some View { + modifier(PhotoPreviewModifier.PresentationBackgroundModifier(color: color)) + } +} diff --git a/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+Configuration.swift b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+Configuration.swift new file mode 100644 index 00000000..2522c65b --- /dev/null +++ b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+Configuration.swift @@ -0,0 +1,43 @@ +// +// PhotoPreview+Configuration.swift +// PovioKit +// +// Created by Ndriqim Nagavci on 09/09/2024. +// Copyright © 2024 Povio Inc. All rights reserved. +// + +import SwiftUI + +@available(iOS 16.0, *) +public extension PhotoPreview { + struct Configuration { + public var backgroundColor: Color + public var showDismissButton: Bool + public var velocityThreshold: CGSize + public var offsetThreshold: CGFloat + + public init( + backgroundColor: Color, + showDismissButton: Bool, + velocityThreshold: CGSize, + offsetThreshold: CGFloat + ) { + self.backgroundColor = backgroundColor + self.showDismissButton = showDismissButton + self.velocityThreshold = velocityThreshold + self.offsetThreshold = offsetThreshold + } + } +} + +@available(iOS 16.0, *) +public extension PhotoPreview.Configuration { + static var `default`: PhotoPreview.Configuration { + .init( + backgroundColor: .black, + showDismissButton: true, + velocityThreshold: .init(width: 200, height: 1000), + offsetThreshold: 80 + ) + } +} diff --git a/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+ImageLoader.swift b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+ImageLoader.swift new file mode 100644 index 00000000..4f875b5c --- /dev/null +++ b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+ImageLoader.swift @@ -0,0 +1,50 @@ +// +// PhotoPreview+ImageLoader.swift +// PovioKit +// +// Created by Ndriqim Nagavci on 23/09/2024. +// Copyright © 2024 Povio Inc. All rights reserved. +// + +import SwiftUI + +@available(iOS 16.0, *) +extension PhotoPreview { + @MainActor + class ImageLoader: ObservableObject { + @Published private(set) var state: State = .initial + + func loadImage(from url: URL?) async { + guard let url else { return } + state = .loading + let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad) + do { + let (data, _) = try await URLSession.shared.data(for: request) + await getImageFrom(data: data) + } catch { + state = .failed + } + } + + func getImageFrom(data: Data) async { + guard let loadedImage = UIImage(data: data) else { + state = .failed + return + } + withAnimation(.linear(duration: 1)) { + state = .loaded(loadedImage) + } + } + } +} + +// MARK: - State +@available(iOS 16.0, *) +extension PhotoPreview.ImageLoader { + enum State: Equatable { + case initial + case loading + case loaded(UIImage) + case failed + } +} diff --git a/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+Item.swift b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+Item.swift new file mode 100644 index 00000000..509a9635 --- /dev/null +++ b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+Item.swift @@ -0,0 +1,28 @@ +// +// PhotoPreview+Item.swift +// PovioKit +// +// Created by Ndriqim Nagavci on 12/08/2024. +// Copyright © 2024 Povio Inc. All rights reserved. +// + +import SwiftUI + +@available(iOS 16.0, *) +public extension PhotoPreview { + struct Item { + public var image: Image? + public var url: URL? + public var placeholder: Image? + + public init( + image: Image? = nil, + url: URL? = nil, + placeholder: Image? = nil + ) { + self.image = image + self.url = url + self.placeholder = placeholder + } + } +} diff --git a/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+ItemView.swift b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+ItemView.swift new file mode 100644 index 00000000..40ae1ea3 --- /dev/null +++ b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview+ItemView.swift @@ -0,0 +1,237 @@ +// +// PhotoPreview+ItemView.swift +// PovioKit +// +// Created by Ndriqim Nagavci on 12/08/2024. +// Copyright © 2024 Povio Inc. All rights reserved. +// + +#if canImport(Kingfisher) +import Kingfisher +#endif +import SwiftUI + +@available(iOS 16.0, *) +extension PhotoPreview { + struct ItemView: View { + typealias VoidHandler = () -> Swift.Void + typealias DragHandler = (CGFloat) -> Swift.Void + + @StateObject var imageLoader = ImageLoader() + @Binding var dragEnabled: Bool + @Binding var currentIndex: Int + @Binding var verticalOffset: CGFloat + @State var scale: CGFloat = 1.0 + @State var lastScaleValue: CGFloat = 1.0 + @State var offset = CGSize.zero + @State var lastOffset = CGSize.zero + @State var initialDragOffset: CGSize = .zero + let item: Item + let myIndex: Int + let onDragChanged: DragHandler + let onDragEnded: VoidHandler + let dragHorizontalPadding: CGFloat = 24 + let screenSize: CGSize = UIScreen.main.bounds.size + + var body: some View { + imageView + .scaleEffect(scale) + .offset(offset) + .offset(y: verticalOffset) + .gesture(magnificationGesture) + .simultaneousGesture(dragEnabled ? dragGesture : nil) + .onTapGesture(count: 2) { + handleDoubleTap() + } + .onChange(of: scale) { value in + dragEnabled = value != 1.0 + } + .onChange(of: dragEnabled) { value in + guard value, offset != .zero else { + return + } + endDrag(animated: true) + } + .onChange(of: currentIndex) { index in + if index != myIndex { + withAnimation { + resetScaleAndPosition() + } + } + } + } + } +} + +// MARK: - Views +@available(iOS 16.0, *) +extension PhotoPreview.ItemView { + var imageView: some View { + Group { + if let image = item.image { + image + .resizable() + .scaledToFit() + .aspectRatio(contentMode: .fit) + } else { + remoteImageView + } + } + } + + var remoteImageView: some View { + #if canImport(Kingfisher) + KFImage(item.url) + .resizable() + .placeholder { + (item.placeholder ?? Image(uiImage: UIImage())) + .resizable() + } + .scaledToFit() + #else + AsyncImage(url: item.url) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + ProgressView() + .tint(.white) + .controlSize(.large) + } + #endif + } + + var loadingView: some View { + Circle() + .trim(from: 0.0, to: 0.5) + .stroke(style: StrokeStyle(lineWidth: 8, lineCap: .round)) + .foregroundColor(.white) + .rotationEffect(Angle(degrees: loadingViewRotation)) + .animation( + Animation.linear(duration: 1.0).repeatForever(autoreverses: false), + value: imageLoader.state + ) + .frame(width: 50, height: 50) + } +} + +// MARK: - Helper methods +@available(iOS 16.0, *) +extension PhotoPreview.ItemView { + func endDrag(animated: Bool = true) { + initialDragOffset = .zero + let imageWidth = screenSize.width * scale + let imageHeight = screenSize.height * scale + let maxXOffset = max((imageWidth - screenSize.width) / 2, 0) + let maxYOffset = max((imageHeight - screenSize.height) / 2, 0) + + var finalOffset = offset + + // Snap back horizontally + if offset.width > maxXOffset { + finalOffset.width = maxXOffset + } else if offset.width < -maxXOffset { + finalOffset.width = -maxXOffset + } + + // Snap back vertically + if offset.height > maxYOffset { + finalOffset.height = maxYOffset + } else if offset.height < -maxYOffset { + finalOffset.height = -maxYOffset + } + + guard animated else { + setOffset(finalOffset) + return + } + withAnimation { + setOffset(finalOffset) + } + } + + func handleDoubleTap() { + withAnimation { + if scale == 1.0 { + scale = 2.0 + } else { + resetScaleAndPosition() + } + } + } + + func setOffset(_ newOffset: CGSize) { + offset = newOffset + lastOffset = newOffset + } + + func resetScaleAndPosition() { + scale = 1.0 + offset = .zero + lastOffset = .zero + } + + var loadingViewRotation: Double { + switch imageLoader.state { + case .loading: + return -360 + default: + return 0 + } + } +} + +// MARK: - Gestures +@available(iOS 16.0, *) +extension PhotoPreview.ItemView { + var magnificationGesture: some Gesture { + MagnificationGesture() + .onChanged { value in + let delta = value / lastScaleValue + lastScaleValue = value + scale = min(max(scale * delta, 1.0), 4.0) + } + .onEnded { _ in + lastScaleValue = 1.0 + if scale <= 1.0 { + withAnimation { + resetScaleAndPosition() + } + } + } + } + + var dragGesture: some Gesture { + DragGesture() + .onChanged { value in + guard scale > 1.0 else { + dragEnabled = false + initialDragOffset = .zero + return + } + if initialDragOffset == .zero { + initialDragOffset = value.translation + } + let newXOffset = lastOffset.width + value.translation.width - initialDragOffset.width + let newYOffset = lastOffset.height + value.translation.height - initialDragOffset.height + offset = CGSize( + width: newXOffset, + height: newYOffset + ) + } + .onEnded { value in + guard scale > 1.0 else { return } + // Vertical limit handling + let imageHeight = screenSize.height * scale + let maxYOffset = max((imageHeight - screenSize.height) / 2, 0) + dragHorizontalPadding + + // Animate to the middle if out of bounds + if offset.height >= maxYOffset || offset.height <= -maxYOffset { + withAnimation { + offset.height = 0 + } + } + endDrag() + } + } +} diff --git a/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview.swift b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview.swift new file mode 100644 index 00000000..97e167cb --- /dev/null +++ b/Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview.swift @@ -0,0 +1,231 @@ +// +// PhotoPreview.swift +// PovioKit +// +// Created by Ndriqim Nagavci on 12/08/2024. +// Copyright © 2024 Povio Inc. All rights reserved. +// + +#if os(iOS) +import SwiftUI + +@available(iOS 16.0, *) +public struct PhotoPreview: View { + let items: [Item] + let configuration: Configuration + @Binding var presented: Bool + @State var position: Int? + @State var currentIndex: Int = 0 + @State var offset: CGFloat = 0 + @State var verticalOffset: CGFloat = 0 + @State var imageViewDragEnabled: Bool = false + @State var imageViewLastOffset: CGFloat = 0 + @State var dragDirection: Direction = .none + @State var dragVelocity: CGFloat = 0 + @State var shouldSwitchDragDirection: Bool = true + @State var backgroundOpacity: CGFloat = 1 + @State var initialDragOffset: CGFloat = .zero + @State var horizontalOffset: CGFloat = .zero + + public init( + items: [Item], + configuration: Configuration, + presented: Binding + ) { + self.items = items + self.configuration = configuration + _presented = presented + } + + public var body: some View { + scrollView + .ignoresSafeArea() + .overlay { + if configuration.showDismissButton { + dismissView + .opacity(hideDismissButton ? 0 : 1) + } + } + .background( + configuration + .backgroundColor + .opacity(backgroundOpacity) + ) + } +} + +// MARK: - ViewBuilders +@available(iOS 16.0, *) +extension PhotoPreview { + var scrollView: some View { + GeometryReader { geometry in + TabView { + ForEach(0.. CGFloat { + -CGFloat(currentIndex) * geometry.size.width + offset + } + + func resetOffset() { + offset = 0 + } + + func horizontalDragChanged(with value: DragGesture.Value) { + if initialDragOffset == .zero && imageViewLastOffset == .zero { + // we store the initial offset to avoid views from jumping + initialDragOffset = value.translation.width + } + offset = value.translation.width - imageViewLastOffset - initialDragOffset + if imageViewLastOffset == .zero { + dragVelocity = value.predictedEndLocation.x - value.location.x + } + } + + func horizontalDragEnded(with pageWidth: CGFloat) { + dragDirection = .none + initialDragOffset = .zero + let threshold = pageWidth / 3 + if offset < -threshold && currentIndex < items.count - 1 { + withAnimation { + currentIndex += 1 + resetOffset() + } + } else if offset > threshold && currentIndex > 0 { + withAnimation { + currentIndex -= 1 + resetOffset() + } + } else if !imageViewDragEnabled, abs(dragVelocity) > configuration.velocityThreshold.width { + withAnimation { + let dragDirection = dragVelocity > .zero ? -1 : 1 + var updatedIndex = currentIndex + dragDirection + // Ensure the new index is within bounds + updatedIndex = min(max(updatedIndex, 0), items.count - 1) + currentIndex = updatedIndex + resetOffset() + } + } else { + withAnimation { + resetOffset() + } + } + } + + func verticalDragChanged(with value: DragGesture.Value) { + dragDirection = .vertical + verticalOffset = value.translation.height + dragVelocity = value.predictedEndLocation.y - value.location.y + updateBackgroundOpacity() + } + + func verticalDragEnded() { + shouldSwitchDragDirection = true + dragDirection = .none + if dragVelocity > configuration.velocityThreshold.height || verticalOffset > configuration.offsetThreshold { + presented.toggle() + return + } + withAnimation { + verticalOffset = 0 + backgroundOpacity = 1 + } + } + + func dragEnded(with pageWidth: CGFloat) { + if imageViewLastOffset != 0 { + imageViewDragEnabled = true + } + imageViewLastOffset = 0 + + if dragDirection == .horizontal { +// horizontalDragEnded(with: pageWidth) + } else { + verticalDragEnded() + } + } + + func updateBackgroundOpacity() { + let height = UIScreen.main.bounds.height / 2 + let progress = verticalOffset / height + withAnimation { + backgroundOpacity = 1.0 - progress + } + } + + var hideDismissButton: Bool { + backgroundOpacity < 1.0 + } +} + +// MARK: - Gestures +@available(iOS 16.0, *) +extension PhotoPreview { + func dragGesture(with geometry: GeometryProxy) -> some Gesture { + DragGesture() + .onChanged { value in + guard !imageViewDragEnabled else { return } + if offset > 30 || verticalOffset > 30 { + shouldSwitchDragDirection = false + } + if dragDirection == .none || shouldSwitchDragDirection { + dragDirection = abs(value.translation.width) > abs(value.translation.height) ? .horizontal : .vertical + } + if dragDirection == .horizontal { +// horizontalDragChanged(with: value) + } else if imageViewLastOffset == .zero, offset == 0 { + verticalDragChanged(with: value) + } + } + .onEnded { _ in + dragEnded(with: geometry.size.width) + } + } +} +#endif