ted
ted

Reputation: 49

How can I scale the height of child items with a magnify gesture while preserving the scroll position in a SwiftUI ScrollView?

I am trying to create a day calendar view, similar to the iOS calendar app. There is a feature on the app where you can pinch the calendar to change the distance between hours, which I am trying to replicate.

In the code below, I have managed to get the magnification working. However, because the anchor is at the top of the scroll view, all child items get pushed downwards and out of view. What I want is for the anchor to be at the centre of the pinch, so that the point where the user is pinching from remains visible.

import SwiftUI

final class HourItem: Identifiable {
    let id: UUID
    let hour: Int
    
    init(hour: Int) {
        self.id = UUID()
        self.hour = hour
    }
}

struct TestView: View {
    let minHourHeight: CGFloat = 50
    let maxHourHeight: CGFloat = 400
    
    @State private var isZooming: Bool = false
    @State private var previousZoomAmount: CGFloat = 0.0
    @State private var currentZoomAmount: CGFloat = 0.0
    
    private var zoomAmount: CGFloat {
        1 + currentZoomAmount + previousZoomAmount
    }
    
    private var hourHeight: CGFloat {
        100 * zoomAmount
    }
    
    private let currentTime: Date = Date.now
    private let hourItems = (0..<25).map {
        HourItem(hour: $0)
    }
    
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                ForEach(hourItems) { hourItem in
                    HourMarkView(
                        hour: hourItem.hour,
                        height: hourHeight,
                        currentTime: currentTime
                    )
                }
            }
        }
        .simultaneousGesture(magnification)
    }
    
    private var magnification: some Gesture {
        MagnifyGesture(minimumScaleDelta: 0)
            .onChanged(handleZoomChange)
            .onEnded(handleZoomEnd)
    }
    
    private func handleZoomChange(_ value: MagnifyGesture.Value) {
        if !isZooming {
            isZooming = true
        }
        
        let newZoomAmount = value.magnification - 1
        currentZoomAmount = clampedZoomAmount(newZoomAmount)
    }
    
    private func handleZoomEnd(_: MagnifyGesture.Value) {
        isZooming = false
        previousZoomAmount += currentZoomAmount
        currentZoomAmount = 0
    }
    
    private func clampedZoomAmount(_ newZoomAmount: CGFloat) -> CGFloat {
        if hourHeight > maxHourHeight && newZoomAmount > currentZoomAmount {
            return currentZoomAmount - 0.000001
        } else if hourHeight < minHourHeight && newZoomAmount < currentZoomAmount {
            return currentZoomAmount + 0.000001
        }
        
        return newZoomAmount
    }
}

struct HourMarkView: View {
    var hour: Int
    var height: CGFloat
    var currentTime: Date

    var body: some View {
        HStack(spacing: 10) {
            Text(formatTime(hour))
                .font(.caption)
                .fontWeight(.medium)
                .frame(width: 40, alignment: .trailing)
            Rectangle()
                .fill(Color.gray)
                .frame(height: 1)
        }
        .frame(height: height)
        .background(Color.white)
    }
    
    private func formatTime(_ hour: Int) -> String {
        return String(format: "%02d:00", hour)
    }
}

Current behaviour:

Current behaviour

Intended behaviour (iOS Calendar):

Intended behaviour (iOS Calendar)

Upvotes: 4

Views: 152

Answers (3)

Benzy Neez
Benzy Neez

Reputation: 21665

I was thinking that one possible approach to solve this issue might be to wrap the ScrollView with a ScrollViewReader. You could then try to adjust the scroll position during the magnify gesture by calling proxy.scrollTo, using the startAnchor from the gesture as scroll anchor.

However, I tried this and the movement was very jittery. So I suspect, it may not be possible to adjust the scroll position while the magnify gesture is happening. In any case, the granularity of scrollTo is one view item, whereas what you need is a granularity of 1 point.


As an alternative approach, you can implement your own scrolling:

  • Instead of a ScrollView, use a VStack and apply .fixedSize.
  • Add a drag gesture to allow the contents to be offset (to emulate scrolling).
  • To add the effect of inertia scroll, the predicted destination can be examined at end of drag. If the predicted destination differs significantly from the current offset, the predicted destination can be used as the final offset.
  • Use a GeometryReader to measure the screen size, so that the drag offset can be constrained to the screen bounds.
  • An .onGeometryChange modifier can be used to measure the height of the VStack.
  • When a magnify gesture starts, capture the start position and the fraction of the view that this position represents.
  • The height of the content will change when the zoom factor is changed. When this happens, adjust the y-offset to keep the fraction at the same position.
  • A scroll indicator can be shown as an overlay. If there is no gesture in progress, the indicator can be hidden.

For combining magnification factors, I would suggest using multiplication, rather than addition. When addition is used, magnifying up (making larger) is fast, but magnifying down (making smaller) is slow and often requires multiple gestures. By multiplying the factors, magnifying down is made more responsive. It also allows the logic to be simplified, because there is no need to subtract 1 from the magnification factor delivered by the gesture.

EDIT The solution originally used a GestureState for recording the drag offset. However, with the addition of a scroll indicator, errors about "Invalid sample AnimatablePair" started appearing in the console. By changing the GestureState to a regular State variable and performing all gesture changes using withAnimation, these errors can be prevented. See this answer for more details.

Here is the updated example to show it all working:

struct TestView: View {
    let minHourHeight: CGFloat = 50
    let maxHourHeight: CGFloat = 400
    let defaultHourHeight: CGFloat = 100
    let thresholdInertiaScroll: CGFloat = 50
    let scrollIndicatorWidth: CGFloat = 3

    @State private var previousZoomAmount: CGFloat = 1.0
    @State private var currentZoomAmount: CGFloat = 1.0

    @State private var yOffset = CGFloat.zero
    @State private var dragOffset: CGFloat?
    @State private var contentHeight = CGFloat.zero
    @State private var magnifyStartFraction: CGFloat?
    @State private var magnifyStartLocation = CGPoint.zero

    private var zoomAmount: CGFloat {
        previousZoomAmount * currentZoomAmount
    }

    private let currentTime: Date = Date.now
    private let hourItems = (0..<25).map {
        HourItem(hour: $0)
    }

    private func constrainOffsetToBounds(parentHeight: CGFloat) {
        let minOffset = min(0, parentHeight - contentHeight)
        let correctedOffset = max(minOffset, min(0, yOffset))
        if yOffset != correctedOffset {
            withAnimation(.timingCurve(0, 0.6, 0.4, 1, duration: 0.5)) {
                yOffset = correctedOffset
            }
        }
    }

    private var totalOffset: CGFloat {
        yOffset + (dragOffset ?? 0)
    }

    private var isGestureInProgress: Bool {
        dragOffset != nil || magnifyStartFraction != nil
    }

    var body: some View {
        GeometryReader { proxy in
            let parentHeight = proxy.size.height
            VStack(spacing: 0) {
                ForEach(hourItems) { hourItem in
                    HourMarkView(
                        hour: hourItem.hour,
                        height: defaultHourHeight * zoomAmount,
                        currentTime: currentTime
                    )
                }
            }
            .fixedSize(horizontal: false, vertical: true)
            .offset(y: totalOffset)
            .gesture(drag)
            .simultaneousGesture(magnification)
            .task(id: isGestureInProgress) {
                if !isGestureInProgress {
                    constrainOffsetToBounds(parentHeight: parentHeight)
                }
            }
            .onGeometryChange(for: CGFloat.self) { proxy in
                proxy.size.height
            } action: { height in
                contentHeight = height
                if let magnifyStartFraction {
                    yOffset = magnifyStartLocation.y - (magnifyStartFraction * height)
                }
            }
            .overlay(alignment: .topTrailing) {
                if contentHeight > 0 {
                    scrollIndicator(parentHeight: parentHeight)
                }
            }
        }
    }

    private var drag: some Gesture {
        DragGesture()
            .onChanged { value in
                withAnimation(.easeInOut(duration: 0)) {
                    dragOffset = value.translation.height
                }
            }
            .onEnded { value in
                dragOffset = nil
                yOffset += value.translation.height
                let dHeight = value.predictedEndTranslation.height - value.translation.height
                if abs(dHeight) > thresholdInertiaScroll {
                    let pos = min(1, abs(value.velocity.height) / 20000)
                    withAnimation(.timingCurve(0, pos, 0.4, 1, duration: 0.8)) {
                        yOffset += dHeight
                    }
                }
            }
    }

    private var magnification: some Gesture {
        MagnifyGesture(minimumScaleDelta: 0)
            .onChanged { value in
                if magnifyStartFraction == nil, contentHeight > 0 {
                    magnifyStartLocation = value.startLocation
                    magnifyStartFraction = (value.startLocation.y - yOffset) / contentHeight
                }
                withAnimation(.easeInOut(duration: 0)) {
                    currentZoomAmount = clampedZoomAmount(value.magnification)
                }
            }
            .onEnded { value in
                previousZoomAmount *= currentZoomAmount
                currentZoomAmount = 1.0
                magnifyStartFraction = nil
            }
    }

    private func clampedZoomAmount(_ newZoomAmount: CGFloat) -> CGFloat {
        let result: CGFloat
        let newHourHeight = defaultHourHeight * previousZoomAmount * newZoomAmount
        if newHourHeight > maxHourHeight {
            result = maxHourHeight / (defaultHourHeight * previousZoomAmount)
        } else if newHourHeight < minHourHeight {
            result = minHourHeight / (defaultHourHeight * previousZoomAmount)
        } else {
            result = newZoomAmount
        }
        return result
    }

    private func scrollIndicator(parentHeight: CGFloat) -> some View {
        let h = min(1, parentHeight / contentHeight) * parentHeight
        let topPadding = -totalOffset * parentHeight / contentHeight
        let bottomPadding = parentHeight - h - topPadding
        return Capsule()
            .fill(.gray)
            .frame(maxHeight: h)
            .padding(.top, topPadding > 0 ? topPadding : 0)
            .padding(.bottom, topPadding < 0 ? bottomPadding : 0)
            .frame(width: scrollIndicatorWidth, height: parentHeight, alignment: .top)
            .padding(.trailing, scrollIndicatorWidth)
            .opacity(isGestureInProgress ? 0.8 : 0)
            .animation(
                .easeInOut(duration: isGestureInProgress ? 0.1 : 0.3)
                .delay(isGestureInProgress ? 0 : 1),
                value: isGestureInProgress
            )
    }
}

I would also suggest some small changes in HourMarkView too:

  • The properties can all be declared with let.
  • Set minWidth instead of width when setting the frame on the text.
  • Set alignment: .top when setting the height of the view.
// HourMarkView

let hour: Int
let height: CGFloat
let currentTime: Date

// ...

HStack(spacing: 10) {
    Text(formatTime(hour))
        // ...
        .frame(minWidth: 40, alignment: .trailing) // 👈 minWidth instead of width
    Rectangle()
        // ...
}
.frame(height: height, alignment: .top) // 👈 alignment added

Animation

Upvotes: 2

Andrei G.
Andrei G.

Reputation: 1557

Here's an intermediate answer that maybe with the help of others may lead to something.

The idea is that as views scale, they always do relative to an alignment anchor. When using a ScrollView, everything is top aligned and views can't expand to cover the unoccupied space like they would otherwise, unless a specific size is given.

So if the hour items container could increase in size with an alignment relative to its parent container, the desired effect may be possible.

In example below, I used a .center alignment, but this could maybe be eventually relative to the pinch location, based on a DragGesture. I am not sure if a custom alignment guide may be needed, since it doesn't seem to take a UnitPoint like other modifiers accept as scroll anchor.

Anyway, open to suggestions...

import SwiftUI

struct MagnificationTestView: View {
    
    @State private var spacing: CGFloat = 10
    @State private var wrapperHeight: CGFloat = .zero
    @State private var scrollViewHeight: CGFloat = .zero
    @State private var lastMagnification: CGFloat = 1.0
    
    private let currentTime: Date = Date.now
    
    var body: some View {
        
        //Magnify gesture
        let zoomGesture = MagnificationGesture()
            .onChanged { value in
                let delta = value / lastMagnification
                spacing = max(0, spacing * delta)
                lastMagnification = value
            }
            .onEnded { _ in
                lastMagnification = 1.0
                wrapperHeight = scrollViewHeight
            }
        
        //ScrollView
        ScrollView {
            VStack {
                VStack(spacing: spacing) {
                    ForEach(0..<25, id:\.self) { hourItem in
                        HourMarkView(
                            hour: hourItem,
                            currentTime: currentTime
                        )
                    }
                }
                .background {
                    GeometryReader { geo in
                        Color.clear
                            .onAppear {
                                let initialHeight = geo.frame(in: .scrollView).height
                                wrapperHeight = initialHeight
                            }
                            .onChange(of: geo.frame(in: .scrollView).height ) {
                                scrollViewHeight = geo.frame(in: .global).height
                            }
                    }
                }
                .animation(.linear, value: spacing) // <- when using LazyVStack, this causes floating rows
            }
            .frame(height: wrapperHeight, alignment: .center)
            .clipped()
            .border(.red) // <- for inspection only
        }
        .simultaneousGesture(zoomGesture)
    }
    
}

struct HourMarkView: View {
    var hour: Int
    // var height: CGFloat
    var currentTime: Date
    
    var body: some View {
        VStack(alignment: .leading) {
            HStack(spacing: 10) {
                Text(formatTime(hour))
                    .font(.caption)
                    .fontWeight(.medium)
                    .frame(width: 40, alignment: .trailing)
                Rectangle()
                    .fill(.gray)
                    .frame(height: 1)
            }
        }
    }
    
    private func formatTime(_ hour: Int) -> String {
        return String(format: "%02d:00", hour)
    }
}

#Preview {
    MagnificationTestView()
}

Upvotes: 0

Fattie
Fattie

Reputation: 12372

Purely FYI: not sure how this plays in to SwiftUI.

in UIKit you simply ...

  1. get the original content length and position of the gesture and how far that is from top of the screen (ie subtract offset) and fractional position of the gesture

  2. multiply length by scaling

  3. set new content offset based on the values in (1)

Perhaps all of this is impossible in SwiftUI.


(Bonus. Or instead of 1-2-3, just move the table wholesale based on how far the gesture has moved in the content view.)

Upvotes: 0

Related Questions