Last active
July 24, 2023 03:09
-
-
Save nakamuuu/94a74549ce32b0572e6fb45bfc50a6b7 to your computer and use it in GitHub Desktop.
ImagePager (SwiftUI)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import NukeUI | |
struct ImagePager: View { | |
@State private var pagerState: ImagePagerState | |
let imageUrls: [URL] | |
let onDismiss: () -> Void | |
var body: some View { | |
GeometryReader { geometry in | |
let pageSize = geometry.size | |
HStack(spacing: 0) { | |
ForEach(imageUrls, id: \.absoluteString) { imageUrl in | |
ImagePagerPage( | |
pagerState: $pagerState, | |
imageUrl: imageUrl, | |
pageSize: pageSize, | |
onDismiss: onDismiss | |
).frame(width: pageSize.width, height: pageSize.height) | |
} | |
} | |
.frame(width: pageSize.width * CGFloat(pagerState.pageCount), height: pageSize.height) | |
.offset(pagerState.offset) | |
} | |
} | |
} | |
private struct ImagePagerPage: View { | |
@Binding var pagerState: ImagePagerState | |
let imageUrl: URL? | |
let pageSize: CGSize | |
let onDismiss: () -> Void | |
var body: some View { | |
LazyImage(url: imageUrl) { state in | |
if case .success(let response) = state.result { | |
let imageSize = response.image.size | |
let widthFitSize = CGSize( | |
width: pageSize.width, | |
height: imageSize.height * (pageSize.width / imageSize.width) | |
) | |
let heightFitSize = CGSize( | |
width: imageSize.width * (pageSize.height / imageSize.height), | |
height: pageSize.height | |
) | |
let fitImageSize = widthFitSize.height > pageSize.height ? heightFitSize : widthFitSize | |
Image(uiImage: response.image) | |
.resizable() | |
.scaledToFit() | |
.frame(width: pageSize.width, height: pageSize.height) | |
.modifier( | |
ImageGestureModifier( | |
pageSize: pageSize, | |
imageSize: fitImageSize, | |
onDraggingOver: { | |
pagerState.moveToDesiredOffset(pageSize: pageSize, additionalOffset: $0) | |
}, | |
onDraggingOverEnded: { predictedEndTranslation in | |
// 水平方向のドラッグ操作が完了した後、 `predictedEndTranslation` (慣性を考慮した移動量)を基に前後のページへ移動する | |
let scrollThreshold = pageSize.width / 2.0 | |
withAnimation(.easeOut) { | |
if predictedEndTranslation.width < -scrollThreshold { | |
pagerState.scrollToNextPage(pageSize: pageSize) | |
} else if predictedEndTranslation.width > scrollThreshold { | |
pagerState.scrollToPrevPage(pageSize: pageSize) | |
} else { | |
pagerState.moveToDesiredOffset(pageSize: pageSize) | |
} | |
} | |
// 垂直方向のドラッグ操作が完了した後、 `predictedEndTranslation` を基に必要に応じて画面を閉じる | |
let dismisssThreshold = pageSize.height / 4.0 | |
if abs(predictedEndTranslation.height) > dismisssThreshold { | |
withAnimation(.easeOut) { | |
pagerState.invokeDismissTransition( | |
pageSize: pageSize, | |
predictedEndTranslationY: predictedEndTranslation.height | |
) | |
} | |
onDismiss() | |
} | |
}, | |
onDraggingOverCanceled: { | |
pagerState.moveToDesiredOffset(pageSize: pageSize) | |
} | |
) | |
) | |
} | |
} | |
} | |
} | |
// MARK: - ImagePagerState | |
private struct ImagePagerState { | |
private(set) var pageCount: Int | |
private(set) var currentIndex: Int | |
private(set) var offset: CGSize = .zero | |
private var prevIndex: Int { | |
max(currentIndex - 1, 0) | |
} | |
private var nextIndex: Int { | |
min(currentIndex + 1, pageCount - 1) | |
} | |
init(pageCount: Int, initialIndex: Int = 0) { | |
self.pageCount = pageCount | |
self.currentIndex = initialIndex | |
} | |
mutating func scrollToPrevPage(pageSize: CGSize) { | |
currentIndex = prevIndex | |
moveToDesiredOffset(pageSize: pageSize) | |
} | |
mutating func scrollToNextPage(pageSize: CGSize) { | |
currentIndex = nextIndex | |
moveToDesiredOffset(pageSize: pageSize) | |
} | |
mutating func invokeDismissTransition(pageSize: CGSize, predictedEndTranslationY: CGFloat) { | |
moveToDesiredOffset( | |
pageSize: pageSize, | |
additionalOffset: CGSize(width: 0, height: predictedEndTranslationY) | |
) | |
} | |
mutating func moveToDesiredOffset(pageSize: CGSize, additionalOffset: CGSize = .zero) { | |
offset = CGSize( | |
width: -pageSize.width * CGFloat(currentIndex) + additionalOffset.width, | |
height: additionalOffset.height | |
) | |
} | |
} | |
// MARK: - ImageGestureModifier | |
private struct ImageGestureModifier: ViewModifier { | |
let pageSize: CGSize | |
let imageSize: CGSize | |
// 画像端を超えてドラッグし続けた際に呼び出されるコールバック | |
let onDraggingOver: (CGSize) -> Void | |
let onDraggingOverEnded: (CGSize) -> Void | |
let onDraggingOverCanceled: () -> Void | |
@State private var currentScale: CGFloat = 1.0 | |
@State private var previousScale: CGFloat = 1.0 | |
@State private var currentOffset = CGSize.zero | |
@State private var unclampedOffset = CGSize.zero | |
@State private var previousTranslation = CGSize.zero | |
@State private var draggingOverAxis: DraggingOverAxis? | |
// ドラッグ操作用の Gesture | |
var dragGesture: some Gesture { | |
DragGesture(coordinateSpace: .global) | |
.onChanged { value in | |
handleDragGestureValueChanged(value) | |
} | |
.onEnded { value in | |
handleDragGestureValueChanged(value) | |
previousTranslation = .zero | |
unclampedOffset = currentOffset | |
let (draggableRangeX, draggableRangeY) = calculateDraggableRange() | |
if draggingOverAxis == .horizontal { | |
if currentOffset.width <= draggableRangeX.lowerBound || draggableRangeX.upperBound <= currentOffset.width { | |
onDraggingOverEnded(CGSize(width: value.predictedEndTranslation.width, height: 0)) | |
} else { | |
onDraggingOverCanceled() | |
} | |
} else if draggingOverAxis == .vertical { | |
if currentOffset.height <= draggableRangeY.lowerBound || draggableRangeY.upperBound <= currentOffset.height { | |
onDraggingOverEnded(CGSize(width: 0, height: value.predictedEndTranslation.height)) | |
} else { | |
onDraggingOverCanceled() | |
} | |
} | |
draggingOverAxis = nil | |
} | |
} | |
// ピンチインでの拡大・縮小操作用の Gesture | |
var pinchGesture: some Gesture { | |
MagnificationGesture() | |
.onChanged { value in | |
let delta = value / previousScale | |
previousScale = value | |
currentScale = clamp(currentScale * delta, 1.0, 2.5) | |
} | |
.onEnded { _ in | |
previousScale = 1.0 | |
withAnimation { | |
currentOffset = clampInDraggableRange(offset: currentOffset) | |
} | |
} | |
} | |
func body(content: Content) -> some View { | |
content.offset(x: currentOffset.width, y: currentOffset.height) | |
.scaleEffect(currentScale) | |
.clipShape(Rectangle()) | |
.gesture(dragGesture) | |
.simultaneousGesture(pinchGesture) | |
} | |
/// ドラッグ操作の移動量から画像の表示位置(オフセット)を確定させる | |
/// | |
/// 画像端を超えてドラッグしていた場合は移動量を `onDraggingOver` のコールバックに通知する。 | |
private func handleDragGestureValueChanged(_ value: DragGesture.Value) { | |
let delta = CGSize( | |
width: value.translation.width - previousTranslation.width, | |
height: value.translation.height - previousTranslation.height | |
) | |
previousTranslation = CGSize( | |
width: value.translation.width, | |
height: value.translation.height | |
) | |
unclampedOffset = CGSize( | |
width: unclampedOffset.width + delta.width / currentScale, | |
height: unclampedOffset.height + delta.height / currentScale | |
) | |
currentOffset = clampInDraggableRange(offset: unclampedOffset) | |
// 画像端を考慮したオフセット( `currentOffset` )と考慮しないオフセット( `unclampedOffset` )に差がある場合にコールバックを呼び出す | |
// 画像端を超えてドラッグを開始した後はもう一方向の移動量を無視し、前後の画像への切り替えと画面を閉じる操作を同時に機能させない | |
switch draggingOverAxis { | |
case .horizontal: | |
if unclampedOffset.width != currentOffset.width { | |
onDraggingOver(CGSize(width: unclampedOffset.width - currentOffset.width, height: 0)) | |
} else { | |
draggingOverAxis = nil | |
onDraggingOverCanceled() | |
} | |
case .vertical: | |
if unclampedOffset.height != currentOffset.height { | |
onDraggingOver(CGSize(width: 0, height: unclampedOffset.height - currentOffset.height)) | |
} else { | |
draggingOverAxis = nil | |
onDraggingOverCanceled() | |
} | |
case nil: | |
if unclampedOffset != currentOffset { | |
if abs(unclampedOffset.width - currentOffset.width) > abs(unclampedOffset.height - currentOffset.height) { | |
draggingOverAxis = .horizontal | |
onDraggingOver(CGSize(width: unclampedOffset.width - currentOffset.width, height: 0)) | |
} else { | |
draggingOverAxis = .vertical | |
onDraggingOver(CGSize(width: 0, height: unclampedOffset.height - currentOffset.height)) | |
} | |
} | |
} | |
} | |
private func calculateDraggableRange() -> (ClosedRange<CGFloat>, ClosedRange<CGFloat>) { | |
let scaledImageSize = CGSize( | |
width: imageSize.width * currentScale, | |
height: imageSize.height * currentScale | |
) | |
let draggableSize = CGSize( | |
width: max(0, scaledImageSize.width - pageSize.width), | |
height: max(0, scaledImageSize.height - pageSize.height) | |
) | |
return ( | |
-(draggableSize.width / 2 / currentScale)...(draggableSize.width / 2 / currentScale), | |
-(draggableSize.height / 2 / currentScale)...(draggableSize.height / 2 / currentScale) | |
) | |
} | |
private func clampInDraggableRange(offset: CGSize) -> CGSize { | |
let (draggableHorizontalRange, draggableVerticalRange) = calculateDraggableRange() | |
return CGSize( | |
width: clamp( | |
offset.width, | |
draggableHorizontalRange.lowerBound, | |
draggableHorizontalRange.upperBound | |
), | |
height: clamp( | |
offset.height, | |
draggableVerticalRange.lowerBound, | |
draggableVerticalRange.upperBound | |
) | |
) | |
} | |
private enum DraggingOverAxis: Equatable { | |
case horizontal | |
case vertical | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment