Reputation: 49
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:
Intended behaviour (iOS Calendar):
Upvotes: 4
Views: 152
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:
ScrollView
, use a VStack
and apply .fixedSize
.GeometryReader
to measure the screen size, so that the drag offset can be constrained to the screen bounds..onGeometryChange
modifier can be used to measure the height of the VStack
.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:
let
.minWidth
instead of width
when setting the frame on the text.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
Upvotes: 2
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
Reputation: 12372
Purely FYI: not sure how this plays in to SwiftUI.
in UIKit you simply ...
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
multiply length by scaling
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