UIViewRepresentable unnecessarily recreates view when its SwiftUI offset property changes causing performance issues

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:

  1. 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, called offScreenBuffer.
  2. 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.
  3. Before buffers are swapped, a value that represents the index of the next image in the onScreenBuffer, called offsetImageSelector gets updated according to the drag direction.
  4. When the animation ended and the component is idle, under the hood offScreenBuffer is copied onto onScreenBuffer and offsetImageSelector is reset to 0.

I’ll include an image to visually represent the concept I’m expressing, starting from when the component first renders.

enter image description here

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:

  1. Update offScreenBuffer and offsetImageSelector (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 is onScreenBuffer[1]; a right swipe exposes onScreenBuffer[0] and a left swipe exposes onScreenBuffer[2].

enter image description here

  1. The change in offsetImageSelector from 0 to 1 causes the animation of the following transition:

enter image description here

  1. When animation @2 is completed, offScreenBuffer overwrites onScreenBuffer and offsetImageSelector is reset to 0, as shown here.

enter image description 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).

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật