performanceswiftuigesturedrag

How to resize SwiftUI views using a drag gesture more performant?


enter image description here

I am trying to use drag gestures to allow the user to both change the positions of views and to resize the views. Dragging to move the views works well but the performance when dragging to resize is poor. I can not figure out what the issues is. I tried adding a drawingGroup() modifier to speed things up and that didn't really help either. I didn't expect it to though because just moving a single, very simple view runs poorly. I assume there's just something about how SwiftUI works that I don't understand. I am having the performance issues running on a 2018 Core i3 Mac Mini.

@main
struct App: App {
    
    @StateObject private var model = TestModel()
    
    let info = CardComponentInfo(type: .text, origin: .init(x: 300, y: 300), size: .init(width: 200, height: 200))
    
    var body: some Scene {
        WindowGroup("Ensemble") {
            ZStack {
                Color.white
                TestComponentView(info: info, model: model)
            }
            .frame(width: 1000, height: 1000)
        }
    }
}

struct TestComponentView: View {
    
    var info: CardComponentInfo
    @ObservedObject var model: TestModel
    
    var body: some View {
        ZStack {
            Label(info.type.title, systemImage: info.type.systemImageName)
            ResizingControlsView { point, deltaX, deltaY in
                model.resizedComponentInfo = info
                model.updateForResize(using: point, deltaX: deltaX, deltaY: deltaY)
                //model.updateForResize(point: point, deltaX: deltaX, deltaY: deltaY) // other udpateForResize may work
            } dragEnded: {
                model.resizeEnded()
            }
        }
        .frame(
            width: model.widthForCardComponent(info: info),
            height: model.heightForCardComponent(info: info)
        )
        .background(info.type.color)
        .position(
            x: model.xPositionForCardComponent(info: info),
            y: model.yPositionForCardComponent(info: info)
        )
        .gesture(
            DragGesture()
                .onChanged { gesture in
                    model.draggedComponentInfo = info
                    model.updateForDrag(deltaX: gesture.translation.width, deltaY: gesture.translation.height)
                }
                .onEnded { _ in
                    model.dragEnded()
                }
        )
    }
}

class TestModel: ObservableObject {
    
    @Published var componentInfos: [CardComponentInfo] = []
    
    @Published var draggedComponentInfo: CardComponentInfo? = nil
    @Published var dragOffset: CGSize? = nil
    
    @Published var selectedComponentInfo: CardComponentInfo? = nil
    
    @Published var selectedTypeToAdd: CardComponentViewType? = nil
    @Published var componentBeingAddedInfo: CardComponentInfo? = nil
    
    @Published var resizedComponentInfo: CardComponentInfo? = nil
    @Published var resizeOffset: CGSize? = nil
    @Published var resizePoint: ResizePoint? = nil

    func widthForCardComponent(info: CardComponentInfo) -> CGFloat {
        let widthOffset = (resizedComponentInfo?.id == info.id) ? (resizeOffset?.width ?? 0.0) : 0.0
        return info.size.width + widthOffset
    }
    
    func heightForCardComponent(info: CardComponentInfo) -> CGFloat {
        let heightOffset = (resizedComponentInfo?.id == info.id) ? (resizeOffset?.height ?? 0.0) : 0.0
        return info.size.height + heightOffset
    }
    
    func xPositionForCardComponent(info: CardComponentInfo) -> CGFloat {
        let xPositionOffset = (draggedComponentInfo?.id == info.id) ? (dragOffset?.width ?? 0.0) : 0.0
        return info.origin.x + (info.size.width / 2.0) + xPositionOffset
    }
    
    func yPositionForCardComponent(info: CardComponentInfo) -> CGFloat {
        let yPositionOffset = (draggedComponentInfo?.id == info.id) ? (dragOffset?.height ?? 0.0) : 0.0
        return info.origin.y + (info.size.height / 2.0) + yPositionOffset
    }
    
    func updateForResize(point: ResizePoint, deltaX: CGFloat, deltaY: CGFloat) {
        resizeOffset = CGSize(width: deltaX, height: deltaY)
        resizePoint = resizePoint
    }
    
    func resizeEnded() {
        guard let resizedComponentInfo, let resizePoint, let resizeOffset else { return }
        var w: CGFloat = resizedComponentInfo.size.width
        var h: CGFloat = resizedComponentInfo.size.height
        var x: CGFloat = resizedComponentInfo.origin.x
        var y: CGFloat = resizedComponentInfo.origin.y
        switch resizePoint {
        case .topLeft:
            w -= resizeOffset.width
            h -= resizeOffset.height
            x += resizeOffset.width
            y += resizeOffset.height
        case .topMiddle:
            h -= resizeOffset.height
            y += resizeOffset.height
        case .topRight:
            w += resizeOffset.width
            h -= resizeOffset.height
        case .rightMiddle:
            w += resizeOffset.width
        case .bottomRight:
            w += resizeOffset.width
            h += resizeOffset.height
        case .bottomMiddle:
            h += resizeOffset.height
        case .bottomLeft:
            w -= resizeOffset.width
            h += resizeOffset.height
            x -= resizeOffset.width
            y += resizeOffset.height
        case .leftMiddle:
            w -= resizeOffset.width
            x += resizeOffset.width
        }
        resizedComponentInfo.size = CGSize(width: w, height: h)
        resizedComponentInfo.origin = CGPoint(x: x, y: y)
        self.resizeOffset = nil
        self.resizePoint = nil
        self.resizedComponentInfo = nil
    }
    
    func updateForDrag(deltaX: CGFloat, deltaY: CGFloat) {
        dragOffset = CGSize(width: deltaX, height: deltaY)
    }
    
    func dragEnded() {
        guard let dragOffset else { return }
        draggedComponentInfo?.origin.x += dragOffset.width
        draggedComponentInfo?.origin.y += dragOffset.height
        draggedComponentInfo = nil
        self.dragOffset = nil
    }
    
    func updateForResize(using resizePoint: ResizePoint, deltaX: CGFloat, deltaY: CGFloat) {
        
        guard let resizedComponentInfo else { return }
        
        var width: CGFloat = resizedComponentInfo.size.width
        var height: CGFloat = resizedComponentInfo.size.height
        var x: CGFloat = resizedComponentInfo.origin.x
        var y: CGFloat = resizedComponentInfo.origin.y
        switch resizePoint {
        case .topLeft:
            width -= deltaX
            height -= deltaY
            x += deltaX
            y += deltaY
        case .topMiddle:
            height -= deltaY
            y += deltaY
        case .topRight:
            width += deltaX
            height -= deltaY
            y += deltaY
            print(width, height, x)
        case .rightMiddle:
            width += deltaX
        case .bottomRight:
            width += deltaX
            height += deltaY
        case .bottomMiddle:
            height += deltaY
        case .bottomLeft: //
            width -= deltaX
            height += deltaY
            x += deltaX
        case .leftMiddle:
            width -= deltaX
            x += deltaX
        }
        resizedComponentInfo.size = CGSize(width: width, height: height)
        resizedComponentInfo.origin = CGPoint(x: x, y: y)
    }
}

enum ResizePoint {
    case topLeft, topMiddle, topRight, rightMiddle, bottomRight, bottomMiddle, bottomLeft, leftMiddle
}

struct ResizingControlsView: View {
    
    let borderColor: Color = .white
    let fillColor: Color = .blue
    let diameter: CGFloat = 15.0
    let dragged: (ResizePoint, CGFloat, CGFloat) -> Void
    let dragEnded: () -> Void
    
    var body: some View {
        VStack(spacing: 0.0) {
            HStack(spacing: 0.0) {
                grabView(resizePoint: .topLeft)
                Spacer()
                grabView(resizePoint: .topMiddle)
                Spacer()
                grabView(resizePoint: .topRight)
            }
            Spacer()
            HStack(spacing: 0.0) {
                grabView(resizePoint: .leftMiddle)
                Spacer()
                grabView(resizePoint: .rightMiddle)
            }
            Spacer()
            HStack(spacing: 0.0) {
                grabView(resizePoint: .bottomLeft)
                Spacer()
                grabView(resizePoint: .bottomMiddle)
                Spacer()
                grabView(resizePoint: .bottomRight)
            }
        }
    }
    
    private func grabView(resizePoint: ResizePoint) -> some View {
        var offsetX: CGFloat = 0.0
        var offsetY: CGFloat = 0.0
        let halfDiameter = diameter / 2.0
        switch resizePoint {
        case .topLeft:
            offsetX = -halfDiameter
            offsetY = -halfDiameter
        case .topMiddle:
            offsetY = -halfDiameter
        case .topRight:
            offsetX = halfDiameter
            offsetY = -halfDiameter
        case .rightMiddle:
            offsetX = halfDiameter
        case .bottomRight:
            offsetX = +halfDiameter
            offsetY = halfDiameter
        case .bottomMiddle:
            offsetY = halfDiameter
        case .bottomLeft:
            offsetX = -halfDiameter
            offsetY = halfDiameter
        case .leftMiddle:
            offsetX = -halfDiameter
        }
        return Circle()
            .strokeBorder(borderColor, lineWidth: 3)
            .background(Circle().foregroundColor(fillColor))
            .frame(width: diameter, height: diameter)
            .offset(x: offsetX, y: offsetY)
            .gesture(dragGesture(point: resizePoint))
    }
    
    private func dragGesture(point: ResizePoint) -> some Gesture {
        DragGesture()
            .onChanged { drag in
                switch point {
                case .topLeft:
                    dragged(point, drag.translation.width, drag.translation.height)
                case .topMiddle:
                    dragged(point, 0, drag.translation.height)
                case .topRight:
                    dragged(point, drag.translation.width, drag.translation.height)
                case .rightMiddle:
                    dragged(point, drag.translation.width, 0)
                case .bottomRight:
                    dragged(point, drag.translation.width, drag.translation.height)
                case .bottomMiddle:
                    dragged(point, 0, drag.translation.height)
                case .bottomLeft:
                    dragged(point, drag.translation.width, drag.translation.height)
                case .leftMiddle:
                    dragged(point, drag.translation.width, 0)
                }
            }
            .onEnded { _ in dragEnded() }
    }
}

Solution

  • By default DragGesture will read gestures within the most local coordinate space. In the case of your ResizingControlsView that would be the area within the green rectangle.

    Unfortunately, under these conditions every time you resize the rectangle you are adjusting the coordinate space. DragGesture will register additional movement because the space has moved, which will trigger functions that further move the space, causing the registration of more movement, and so on—leading to a loop condition.

    This can be solved pretty easily by substituting the following code in ResizingControlsView.swift:

    ...
    private func dragGesture(point: ResizePoint) -> some Gesture {
        DragGesture(coordinateSpace: .global)
            .onChanged { drag in
    ...
    

    This solution works by monitoring the global coordinate space, which isn't affected by changes in the position of the green rectangle. However, that means the translated width and height it returns will no longer account for changes in offset like before.

    To account for this, you'll need to subtract the previous offset (deltaX and deltaY) from the new offset manually within updateForResize each time it is called.

    var previousResizeOffset: CGSize? = nil
    ...
    func updateForResize(using resizePoint: ResizePoint, deltaX: CGFloat, deltaY: CGFloat) {
        guard let resizedComponentInfo else { return }
        
        var width: CGFloat = resizedComponentInfo.size.width
        var height: CGFloat = resizedComponentInfo.size.height
        var x: CGFloat = resizedComponentInfo.origin.x
        var y: CGFloat = resizedComponentInfo.origin.y
        
        // Adjust the values of deltaY and deltaX to mimic a local coordinate space.
        let adjDeltaY = deltaY - (previousResizeOffset?.height ?? 0)
        let adjDeltaX = deltaX - (previousResizeOffset?.width ?? 0)
        
        switch resizePoint {
        case .topLeft:
            width -= adjDeltaX
            height -= adjDeltaY
            x += adjDeltaX
            y += adjDeltaY
    ...
    

    In my above example I have saved the previous deltaX and deltaY offsets in a variable, previousResizeOffset, and am subtracting it from new deltaX and deltaY offsets as they come in.