I’m coding a wrap-around carousel component for SwiftUI (minimum target iOS 15.0) with zoom and pan functionality.
The idea is the following:
- There are two buffers: one that contains three images and is shown on screen, called
onScreenBuffer
, and one that’s used to prepare updates for it, of the same size, calledoffScreenBuffer
. - Users can perform drag gestures on the component. When drag ends, if the finger dragged long enough horizontally,
offScreenBuffer
gets recreated from scratch, “centered” on the new index. - Before buffers are swapped, a value that represents the index of the next image in the
onScreenBuffer
, calledoffsetImageSelector
gets updated according to the drag direction. - When the animation ended and the component is idle, under the hood
offScreenBuffer
is copied ontoonScreenBuffer
andoffsetImageSelector
is reset to 0.
I’ll include an image to visually represent the concept I’m expressing, starting from when the component first renders.
Now, for the sake of discussing what’s going to happen, let’s consider the following scenario:
Right Swipe (dragOffset > 0)
When the user drags long enough towards right the component is supposed to show the previous image than the one that’s currently being shown. This is how it’s achieved:
- Update
offScreenBuffer
andoffsetImageSelector
(the latter, to trigger the swipe animation). Note that the offset for the carousel component is(dragOffset - containerWidth)*(1-offsetImageSelector)
, therefore while idle the presented image isonScreenBuffer[1]
; a right swipe exposesonScreenBuffer[0]
and a left swipe exposesonScreenBuffer[2]
.
- The change in
offsetImageSelector
from0
to1
causes the animation of the following transition:
- When animation @2 is completed,
offScreenBuffer
overwritesonScreenBuffer
andoffsetImageSelector
is reset to 0, as shown here.
The Code
I’m omitting the left swipe since it’s perfectly symmetrical. Here’s my current implementation (MRE, the actual component is definitely more complex than that):
PinchableViewRepresentable.swift
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()
}
}
}
CGSize + sizeThatFits.swift
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
}
}
}
ZTronImageModel.swift:
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
)
}
ZTronCarouselModel.swift
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)
}
}
internal var bufferedImagesIDs: [BufferedZTronImageModel] {
return self.imagesIDs.enumerated().map { i, model in
return BufferedZTronImageModel(imageModel: model, position: i)
}
}
internal var imageIDToPositionMap: [String: Int] = [:]
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)
}
}
ZtronCarouselViewAlt.swift
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
}
}
}
}
The Issue
As I recorded here, the PinchableViewRepresentable
component gets recreated (.init() invoked
) and updated updateUIView()
multiple times during drag gesture, and the same thing happens during interface orientation changes, causing bad animations (tested on real device iPhone 6S with iOS 15.8, and iPhone 15 Pro simulator).
Using the SwiftUI instrument it looks like the component re-renders because Binding<CGFloat> and Binding<CGPoint> changed, even though in this MRE default constant values are used for zoomObserver
and scrollObserver
.
Using Self._printChanges() I only get the following:
ZTronCarouselViewAlt: @self, @identity, _carouselModel, _dragOffset changed.
ZTronCarouselViewAlt: _dragOffset changed.
[...]
ZTronCarouselViewAlt: _dragOffset changed.
ZTronCarouselViewAlt: _carouselModel changed.
So during first render @identity changes and after that, only _dragOffset
changes when the redundant updates happen.
The actual PinchableViewRepresentable
content is way more complex than that as well, and it has an overlay that depends on zoomObserver
and scrollObserver
, so the problem gets pretty bad, mostly during rotations.
For the sake of testing it yourself, you can add to your assets folder any set of 16:9 images named “Police”, “Shutter”, “Depot”, “Cakes”, “Sign” (with that exact case).