我正在为 SwiftUI(最低目标 iOS 15.0)编写一个具有缩放和平移功能的环绕式轮播组件。
想法如下:
onScreenBuffer
,另一个用于为其准备更新,大小相同,称为 offScreenBuffer
。offScreenBuffer
将从头开始重新创建,“居中”在新索引上。onScreenBuffer
中下一个图像的索引(称为 offsetImageSelector
)的值会根据拖动方向进行更新。offScreenBuffer
会被复制到 onScreenBuffer
上,并且 offsetImageSelector
会重置为 0。我将包含一个图像来直观地表示我所表达的概念,从组件首次渲染时开始。
现在,为了讨论将要发生的情况,让我们考虑以下场景:
当用户向右拖动足够长的时间时,组件应该显示上一张图像而不是当前显示的图像。这就是它的实现方式:
offScreenBuffer
和 offsetImageSelector
(后者用于触发滑动动画)。请注意,轮播组件的偏移量为 (dragOffset - containerWidth)*(1-offsetImageSelector)
,因此空闲时呈现的图像为 onScreenBuffer[1]
;向右滑动可显示 onScreenBuffer[0]
,向左滑动可显示 onScreenBuffer[2]
。offsetImageSelector
从0
到1
的变化会导致以下过渡的动画:offScreenBuffer
会覆盖 onScreenBuffer
并且 offsetImageSelector
会重置为 0,如下所示。我省略了向左滑动,因为它是完全对称的。这是我当前的实现(MRE,实际组件肯定比这更复杂):
import SwiftUI
import Combine
struct PinchableViewRepresentable<Content: View>: UIViewRepresentable {
var id: String
private var content: () -> Content
internal var minimumZoomScale: CGFloat = 1.0
internal var maximumZoomScale: CGFloat = 20.0
internal var bouncesZoom: Bool = true
internal var resetZoomOnDoubleTap: Bool = true
internal var onDoubleTap: (() -> Void)?
internal var onSingleTap: (() -> Void)?
@Binding internal var zoomObserver: CGFloat
@Binding internal var scrollObserver: CGPoint
init(
id: String = UUID().uuidString,
zoomObserver: Binding<CGFloat> = .constant(0),
scrollObserver: Binding<CGPoint> = .constant(.zero),
@ViewBuilder content: @escaping () -> Content,
onSingleTap: (()->Void)? = nil,
onDoubleTap: (()->Void)? = nil
) {
self._zoomObserver = zoomObserver
self._scrollObserver = scrollObserver
self.content = content
self.onSingleTap = onSingleTap
self.onDoubleTap = onDoubleTap
self.id = id
print("PinchableViewRepresentable#\(id) init")
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = self.maximumZoomScale
scrollView.minimumZoomScale = self.minimumZoomScale
scrollView.bouncesZoom = self.bouncesZoom
// create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
scrollView.addSubview(hostedView)
hostedView.backgroundColor = .clear
context.coordinator.scrollView = scrollView
context.coordinator.onSingleTap = self.onSingleTap
context.coordinator.onDoubleTap = self.onDoubleTap
if self.resetZoomOnDoubleTap {
let doubleTapGestureRecognizer = UITapGestureRecognizer(
target: context.coordinator,
action: #selector(context.coordinator.handleDoubleTap(_:))
)
doubleTapGestureRecognizer.numberOfTapsRequired = 2
scrollView.addGestureRecognizer(doubleTapGestureRecognizer)
context.coordinator.doubleTapRecognizer = doubleTapGestureRecognizer
let singleTapGestureRecognizer = UITapGestureRecognizer(
target: context.coordinator,
action: #selector(context.coordinator.handleSingleTap(_:))
)
singleTapGestureRecognizer.numberOfTapsRequired = 1
scrollView.addGestureRecognizer(singleTapGestureRecognizer)
context.coordinator.singleTapRecognizer = singleTapGestureRecognizer
singleTapGestureRecognizer.require(toFail: doubleTapGestureRecognizer)
}
context.coordinator.setZoomObserver(self._zoomObserver)
context.coordinator.setScrollObserver(self._scrollObserver)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content()))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
print("PinchableViewRepresentable#\(id) updateUIView")
context.coordinator.scrollView = uiView
context.coordinator.hostingController.rootView = self.content()
let updateTask = {
context.coordinator.setZoomObserver(self._zoomObserver)
context.coordinator.setScrollObserver(self._scrollObserver)
// uiView.zoomScale = self.zoomObserver
// uiView.contentOffset = self.scrollObserver
}
DispatchQueue.main.async {
updateTask()
}
assert(context.coordinator.hostingController.view.superview == uiView)
}
// MARK: - Coordinator
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
var scrollView: UIScrollView? = nil
var onDoubleTap: (() -> Void)? = nil
var onSingleTap: (() -> Void)? = nil
@Binding var zoomObserver: CGFloat
@Binding var scrollObserver: CGPoint
var doubleTapRecognizer: UITapGestureRecognizer!
var singleTapRecognizer: UITapGestureRecognizer!
var lastFiredZoom: Date = .distantPast
var lastFiredScroll: Date = .distantPast
init(
hostingController: UIHostingController<Content>,
onSingleTap: (() -> Void)? = nil,
onDoubleTap: (() -> Void)? = nil,
zoomObserver: Binding<CGFloat> = .constant(.zero),
scrollObserver: Binding<CGPoint> = .constant(.zero)
) {
self.hostingController = hostingController
self.onSingleTap = onSingleTap
self.onDoubleTap = onDoubleTap
self._zoomObserver = zoomObserver
self._scrollObserver = scrollObserver
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
if #unavailable(iOS 16.0) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.lastFiredZoom.distance(to: Date.now) > 1.0/90.0 {
zoomObserver = scrollView.zoomScale
self.lastFiredZoom = Date.now
}
}
} else {
zoomObserver = scrollView.zoomScale
}
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
zoomObserver = scrollView.zoomScale
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
scrollObserver = scrollView.contentOffset
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if #unavailable(iOS 16.0) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.lastFiredScroll.distance(to: Date.now) > 1.0/90.0 {
self.scrollObserver = scrollView.contentOffset
self.lastFiredScroll = Date.now
}
}
} else {
self.scrollObserver = scrollView.contentOffset
}
}
func setZoomObserver(_ zoomObserver: Binding<CGFloat>) {
DispatchQueue.main.async {
self._zoomObserver = zoomObserver
}
}
func setScrollObserver(_ scrollObserver: Binding<CGPoint>) {
DispatchQueue.main.async {
self._scrollObserver = scrollObserver
}
}
@objc dynamic func handleDoubleTap(_ sender: UITapGestureRecognizer) {
guard let scrollView = self.scrollView else { return }
let zoomScale = scrollView.zoomScale
switch sender.state {
case .ended:
scrollView.setZoomScale(1.0, animated: true)
if zoomScale == 1.0 {
let imageView = scrollView.subviews.first!
let point = doubleTapRecognizer.location(in: imageView)
let scrollSize = scrollView.frame.size
let size = CGSize(width: scrollSize.width / (scrollView.maximumZoomScale/2.0),
height: scrollSize.height / (scrollView.maximumZoomScale/2.0))
let origin = CGPoint(x: point.x - size.width / 2,
y: point.y - size.height / 2)
scrollView.zoom(to:CGRect(origin: origin, size: size), animated: true)
self.onDoubleTap?()
}
default:
break
}
}
@objc dynamic func handleSingleTap(_ sender: UITapGestureRecognizer) {
guard let onSingleTap = self.onSingleTap else { return }
onSingleTap()
}
}
}
import CoreGraphics
extension CGSize {
static func sizeThatFits(containerSize: CGSize, containedAR: CGFloat) -> CGSize {
if containedAR.isZero || containedAR.isNaN || containedAR.isInfinite {
#if DEBUG
print("Unexpected input parameter `containedAR` value: \(containedAR)")
#endif
return .zero
} else {
var proposedSize: CGSize = .zero
if containedAR > containerSize.width / containerSize.height {
proposedSize.width = containerSize.width
proposedSize.height = containerSize.width / containedAR
} else {
proposedSize.height = containerSize.height
proposedSize.width = containerSize.height * containedAR
}
#if DEBUG
if proposedSize.width > containerSize.width || proposedSize.height > containerSize.height {
print("Computed size \(proposedSize) unexpectedly exceeds container size \(containerSize)")
}
#endif
return proposedSize
}
}
}
import Foundation
class ZTronImageModel: ObservableObject {
private var imageID: String
private var position: Int
var id: String
init(image: String, position: Int) {
self.imageID = image
self.position = position
self.id = imageID
}
static func == (lhs: ZTronImageModel, rhs: ZTronImageModel) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(imageID)
}
func getName() -> String {
return self.imageID
}
func getPosition() -> Int {
return self.position
}
static var zero: ZTronImageModel = ZTronImageModel(
image: "YadaYadaYada",
position: 0
)
}
import SwiftUI
class ZtronCarouselModel: ObservableObject {
internal var onScreenBuffer: [BufferedZTronImageModel] = []
private var offScreenBuffer: [BufferedZTronImageModel] = []
internal var currentImage: Int = 0
@Published internal var offsetImageSelector: Int = 0
//MARK: - Images set
@Published internal var imagesIDs: [ZTronImageModel] = [] {
didSet {
guard imagesIDs.count > 0 else { return }
self.makeBuffer(for: self.currentImage, buffer: &self.onScreenBuffer)
self.makeBuffer(for: self.currentImage, buffer: &self.offScreenBuffer)
}
}
init(){
imagesIDs = [
ZTronImageModel(image: "Police", position: 0),
ZTronImageModel(image: "Shutter", position: 1),
ZTronImageModel(image: "Depot", position: 2),
ZTronImageModel(image: "Cakes", position: 3),
ZTronImageModel(image: "Sign", position: 4)
]
}
//MARK: - ViewController
internal func makeBuffer(for centralIdx: Int, buffer: inout [BufferedZTronImageModel]) {
precondition(centralIdx >= 0 && centralIdx < self.imagesIDs.count)
var tempBuffer: [BufferedZTronImageModel] = []
if tempBuffer.count < 3 {
tempBuffer = [BufferedZTronImageModel].init(repeating: .zero, count: 3)
}
for i in -1...1 {
let next = (centralIdx + i + imagesIDs.count) % imagesIDs.count
tempBuffer[i+1] = BufferedZTronImageModel(imageModel: imagesIDs[next], position: i+1)
}
buffer = tempBuffer
}
func prepareForNext(forward: Bool = true) {
let nextIdx = forward ? (currentImage+1)%imagesIDs.count : (currentImage-1+imagesIDs.count) % imagesIDs.count
self.offsetImageSelector += forward ? -1 : 1
assert(nextIdx >= 0 && nextIdx <= imagesIDs.count)
self.makeBuffer(for: nextIdx, buffer: &self.offScreenBuffer)
currentImage = nextIdx
}
func swapOnscreenBuffer() {
self.onScreenBuffer = Array(self.offScreenBuffer)
self.offsetImageSelector = 0
}
internal struct BufferedZTronImageModel: Identifiable {
var imageModel: ZTronImageModel
var position: Int
var id: String
init(imageModel: ZTronImageModel, position: Int) {
self.imageModel = imageModel
self.position = position
//self.id = "\(imageModel.id)\(position)"
self.id = imageModel.id
}
public static let zero = BufferedZTronImageModel(imageModel: .zero, position: .zero)
}
}
import SwiftUI
struct ZTronCarouselViewAlt: View {
@StateObject private var carouselModel = ZtronCarouselModel()
@State private var dragOffset: CGFloat = 0
var body: some View {
GeometryReader { geo in
let expectedImageVFraction = CGSize.sizeThatFits(containerSize: geo.size, containedAR: 16.0/9.0).height / geo.size.height
let needsVerticalLayout = expectedImageVFraction <= 0.75
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
ForEach(carouselModel.onScreenBuffer, id: \.id) { bufferedImage in
PinchableViewRepresentable(id: bufferedImage.imageModel.getName()) {
Image(bufferedImage.imageModel.getName())
.resizable()
}
.frame(width: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing, alignment: .leading)
.overlay {
Text("\(carouselModel.offsetImageSelector)")
.foregroundStyle(.white)
}
}
}
.zIndex(1.0)
.offset(
x: (dragOffset - (geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing)) * CGFloat((1 - carouselModel.offsetImageSelector))
)
.aspectRatio(16.0/9.0, contentMode: .fit)
.gesture(
DragGesture(minimumDistance: 20)
.onChanged { value in
if carouselModel.offsetImageSelector == 0 {
dragOffset = value.translation.width
} else {
dragOffset = 0
}
}
.onEnded { _ in
onDragEnded(galleryWidth: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing)
}
)
.frame(maxWidth: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing, alignment: .leading)
.contentShape(Rectangle())
.clipped()
}
.frame(maxHeight: .infinity, alignment: .center)
.ignoresSafeArea(.container, edges: [.leading, .trailing])
.fixedSize(horizontal: false, vertical: needsVerticalLayout)
}
}
}
private func onDragEnded(galleryWidth: CGFloat) {
let swipePercentage = abs(dragOffset) / galleryWidth
if swipePercentage > 0.2 {
withAnimation(.easeOut(duration: 0.25)) {
carouselModel.prepareForNext(forward: dragOffset < 0)
dragOffset = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
carouselModel.swapOnscreenBuffer()
}
} else {
print("\(#function) path 1.2")
withAnimation {
self.dragOffset = 0
}
}
}
}
正如我在此处记录的那样,在拖动手势期间,PinchableViewRepresentable
组件会被重新创建(
.init() invoked
)并更新updateUIView()
多次,并且在界面方向更改期间也会发生同样的情况,导致动画效果不佳(在真机iPhone上测试) 6S(带 iOS 15.8)和 iPhone 15 Pro 模拟器)。使用 SwiftUI 工具,看起来组件会重新渲染,因为 Binding 和 Binding 发生了变化,即使在此 MRE 中,zoomObserver
scrollObserver
使用默认常量值。使用 Self._printChanges() 我只得到以下内容:
ZTronCarouselViewAlt: @self, @identity, _carouselModel, _dragOffset changed.
ZTronCarouselViewAlt: _dragOffset changed.
[...]
ZTronCarouselViewAlt: _dragOffset changed.
ZTronCarouselViewAlt: _carouselModel changed.
因此,在第一次渲染 @identity 更改期间,之后,当发生冗余更新时,只有
_dragOffset
发生变化。
实际的PinchableViewRepresentable
内容也比这复杂得多,并且它有一个依赖于
zoomObserver
和 scrollObserver
的覆盖层,因此问题变得非常严重,主要是在旋转期间。为了自己进行测试,您可以将任意一组名为“Police”、“Shutter”、“Depot”、“Cakes”、“Sign”(具有确切的大小写)的 16:9 图像添加到您的资产文件夹中。
编辑
PinchableViewRepresentable
减少为:
,问题仍然存在:
struct PinchableViewRepresentable<Content: View>: UIViewRepresentable {
private var content: Content
init(
@ViewBuilder content: @escaping () -> Content
) {
self.content = content()
print("PinchableViewRepresentable#\(#function)")
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.maximumZoomScale = 1.0
scrollView.minimumZoomScale = 20.0
scrollView.bouncesZoom = true
// create a UIHostingController to hold our SwiftUI content
let hostedView = UIHostingController(rootView: content).view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
scrollView.addSubview(hostedView)
hostedView.backgroundColor = .clear
return scrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
print("PinchableViewRepresentable#\(#function)")
}
}