Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI/PhotoPreview #330

Open
wants to merge 29 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
fc389c9
Model.
nagavcindriqim Aug 13, 2024
44e7cf0
Introduce PhotoPReviewItemView.
nagavcindriqim Aug 13, 2024
1f7fb48
Introduce PhotoPreview.
nagavcindriqim Aug 13, 2024
3af133d
Swipe down to dismiss.
nagavcindriqim Aug 14, 2024
92e211a
Modifier.
nagavcindriqim Aug 14, 2024
8d605df
Ensure new index within bounds.
nagavcindriqim Sep 9, 2024
39e6eb8
Limit zoom in.
nagavcindriqim Sep 9, 2024
71c86a6
Animate vertical out of bounds offset.
nagavcindriqim Sep 9, 2024
1cc132a
Scale to fit remote image.
nagavcindriqim Sep 9, 2024
25619d8
Configuration.
nagavcindriqim Sep 9, 2024
fe907b7
Dismiss button.
nagavcindriqim Sep 9, 2024
fc21ca0
Background opacity.
nagavcindriqim Sep 9, 2024
ce0b821
README.
nagavcindriqim Sep 9, 2024
910fe57
Address PR comments.
nagavcindriqim Sep 10, 2024
cd02de4
Support from iOS 15.
nagavcindriqim Sep 19, 2024
6d271b1
Storybook.
nagavcindriqim Sep 19, 2024
4d50139
Introduce PhotoPreviewImageLoader.
nagavcindriqim Sep 23, 2024
764778f
Load remote images.
nagavcindriqim Sep 23, 2024
513bced
Drawing group.
nagavcindriqim Sep 23, 2024
abee235
Show loader.
nagavcindriqim Sep 23, 2024
6be41b2
Drag outside image bounds.
nagavcindriqim Sep 23, 2024
811c909
Disable zooming out.
nagavcindriqim Sep 23, 2024
647eb3f
Animate loading.
nagavcindriqim Sep 24, 2024
c671cf2
Files under PhotoPreview namespace.
nagavcindriqim Sep 24, 2024
448bd37
Clear presentation background.
nagavcindriqim Sep 24, 2024
e249957
Hide close button when dismissing.
nagavcindriqim Sep 24, 2024
fd2c590
File naming.
nagavcindriqim Sep 24, 2024
8fcdb4a
Fix drag gesture.
nagavcindriqim Sep 24, 2024
960df31
Switch to TabView mid work commit.
nagavcindriqim Sep 26, 2024
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
28 changes: 28 additions & 0 deletions Resources/UI/SwiftUI/PhotoPreview/README.md
Original file line number Diff line number Diff line change
@@ -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
)
}
}
```
1 change: 1 addition & 0 deletions Resources/UI/SwiftUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
45 changes: 45 additions & 0 deletions Sources/UI/SwiftUI/View Modifiers/PhotoPreviewModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// PhotoPreviewModifier.swift
// PovioKit
//
// Created by Ndriqim Nagavci on 14/08/2024.
// Copyright © 2024 Povio Inc. All rights reserved.
//

import SwiftUI

@available(iOS 14.0, *)
borut-t marked this conversation as resolved.
Show resolved Hide resolved
struct PhotoPreviewModifier: ViewModifier {
public typealias VoidHandler = () -> Swift.Void
@Binding var presented: Bool
let items: [PhotoPreviewItem]
let configuration: PhotoPreviewConfiguration
let onDismiss: VoidHandler?

public func body(content: Content) -> some View {
content
.fullScreenCover(isPresented: $presented) {
PhotoPreview(items: items, configuration: configuration) {
borut-t marked this conversation as resolved.
Show resolved Hide resolved
presented.toggle()
onDismiss?()
}
}
}
}

@available(iOS 14.0, *)
public extension View {
func photoPreview(
present: Binding<Bool>,
items: [PhotoPreviewItem],
configuration: PhotoPreviewConfiguration = .defaultConfiguration,
onDismiss: (() -> Swift.Void)? = nil
) -> some View {
modifier(PhotoPreviewModifier(
presented: present,
items: items,
configuration: configuration,
onDismiss: onDismiss
))
}
}
223 changes: 223 additions & 0 deletions Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreview.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
//
// 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 14.0, *)
public struct PhotoPreview: View {
public typealias VoidHandler = () -> Swift.Void
let items: [PhotoPreviewItem]
let configuration: PhotoPreviewConfiguration
let dismiss: VoidHandler
@State var currentIndex = 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

public init(
items: [PhotoPreviewItem],
configuration: PhotoPreviewConfiguration,
dismiss: @escaping VoidHandler
) {
self.items = items
self.configuration = configuration
self.dismiss = dismiss
}

public var body: some View {
ZStack {
scrollView
.ignoresSafeArea()
if configuration.showDismissButton {
dismissView
}
}
borut-t marked this conversation as resolved.
Show resolved Hide resolved
.background(
configuration
.backgroundColor
.ignoresSafeArea()
.opacity(backgroundOpacity)
)
}
}

// MARK: - ViewBuilders
@available(iOS 14.0, *)
extension PhotoPreview {
@ViewBuilder
var scrollView: some View {
borut-t marked this conversation as resolved.
Show resolved Hide resolved
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 0) {
ForEach(0..<items.count, id: \.self) { index in
PhotoPreviewItemView(
dragEnabled: $imageViewDragEnabled,
currentIndex: $currentIndex,
verticalOffset: $verticalOffset,
item: items[index],
myIndex: index
) { newValue in
imageViewLastOffset = newValue
} onDragEnded: {
horizontalDragEnded(with: geometry.size.width)
}
.frame(width: geometry.size.width)
.id(index)
}
}
}
.content
.offset(x: contentOffset(for: geometry))
.simultaneousGesture(dragGesture(with: geometry))
}
}

var dismissView: some View {
VStack {
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.resizable()
.foregroundColor(.white)
.frame(width: 30, height: 30)
.padding(.trailing, 24)
}
.shadow(radius: 2)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
}

// MARK: - Helpers
@available(iOS 14.0, *)
extension PhotoPreview {
enum Direction {
case vertical
case horizontal
case none
}

func contentOffset(for geometry: GeometryProxy) -> CGFloat {
-CGFloat(currentIndex) * geometry.size.width + offset
}

func resetOffset() {
offset = 0
}

func horizontalDragChanged(with value: DragGesture.Value) {
offset = value.translation.width - imageViewLastOffset
if imageViewLastOffset == .zero {
dragVelocity = value.predictedEndLocation.x - value.location.x
}
}

func horizontalDragEnded(with pageWidth: CGFloat) {
dragDirection = .none
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 {
dismiss()
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
}
}
}

// MARK: - Gestures
@available(iOS 14.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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// PhotoPreviewConfiguration.swift
// PovioKit
//
// Created by Ndriqim Nagavci on 09/09/2024.
// Copyright © 2024 Povio Inc. All rights reserved.
//

import SwiftUI

public struct PhotoPreviewConfiguration {
let backgroundColor: Color
let showDismissButton: Bool
let velocityThreshold: CGSize
let offsetThreshold: CGFloat
}
borut-t marked this conversation as resolved.
Show resolved Hide resolved

public extension PhotoPreviewConfiguration {
static var defaultConfiguration: PhotoPreviewConfiguration {
borut-t marked this conversation as resolved.
Show resolved Hide resolved
.init(
backgroundColor: .black,
showDismissButton: true,
velocityThreshold: .init(width: 200, height: 1000),
offsetThreshold: 80
)
}
}
25 changes: 25 additions & 0 deletions Sources/UI/SwiftUI/Views/PhotoPreview/PhotoPreviewItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// PhotoPreviewItem.swift
// PovioKit
//
// Created by Ndriqim Nagavci on 12/08/2024.
// Copyright © 2024 Povio Inc. All rights reserved.
//

import SwiftUI

public struct PhotoPreviewItem {
let image: Image?
let url: URL?
let placeholder: Image?
borut-t marked this conversation as resolved.
Show resolved Hide resolved

public init(
image: Image? = nil,
url: URL? = nil,
placeholder: Image? = nil
) {
self.image = image
self.url = url
self.placeholder = placeholder
}
}
Loading