From 5c6c42ada5a7028a1c248e24b44900f0fb9b979f Mon Sep 17 00:00:00 2001 From: Bogdan Protsenko Date: Sun, 3 Aug 2025 11:51:42 +0700 Subject: [PATCH 1/5] Day view pinch to zoom --- Sources/CalendarStyle.swift | 13 ++- Sources/DayViewState.swift | 2 + Sources/Timeline/TimelinePagerView.swift | 122 ++++++++++++++++++++++- Sources/Timeline/TimelineView.swift | 21 ++-- 4 files changed, 146 insertions(+), 12 deletions(-) diff --git a/Sources/CalendarStyle.swift b/Sources/CalendarStyle.swift index b51271bf..c11132db 100644 --- a/Sources/CalendarStyle.swift +++ b/Sources/CalendarStyle.swift @@ -69,7 +69,18 @@ public struct TimelineStyle { public var eventsWillOverlap: Bool = false public var minimumEventDurationInMinutesWhileEditing: Int = 30 public var splitMinuteInterval: Int = 15 - public var verticalDiff: Double = 50 + + /// Points per one minute of time. Mutated by pinch zoom. + public var pointsPerMinute: CGFloat = 50.0 / 60.0 + public var minimumPointsPerMinute: CGFloat = 0.25 + public var maximumPointsPerMinute: CGFloat = 5.0 + + @available(*, deprecated, message: "Use pointsPerMinute instead") + public var verticalDiff: Double { + get { pointsPerMinute * 60 } + set { pointsPerMinute = newValue / 60 } + } + public var verticalInset: Double = 10 public var leadingInset: Double = 53 public var eventGap: Double = 0 diff --git a/Sources/DayViewState.swift b/Sources/DayViewState.swift index 01ef3244..aff8f2e7 100644 --- a/Sources/DayViewState.swift +++ b/Sources/DayViewState.swift @@ -8,6 +8,8 @@ public final class DayViewState { public private(set) var calendar: Calendar public private(set) var selectedDate: Date private var clients = [DayViewStateUpdating]() + + public var pointsPerMinute: CGFloat = 50.0 / 60.0 public init(date: Date = Date(), calendar: Calendar = Calendar.autoupdatingCurrent) { let date = date.dateOnly(calendar: calendar) diff --git a/Sources/Timeline/TimelinePagerView.swift b/Sources/Timeline/TimelinePagerView.swift index 7e85f1d4..55eece6d 100644 --- a/Sources/Timeline/TimelinePagerView.swift +++ b/Sources/Timeline/TimelinePagerView.swift @@ -45,12 +45,19 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr private lazy var panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + + private lazy var pinchRecognizer = UIPinchGestureRecognizer(target: self, + action: #selector(handlePinch(_:))) public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if otherGestureRecognizer.view is EventResizeHandleView { return false } + if gestureRecognizer == pinchRecognizer || + otherGestureRecognizer is UIPanGestureRecognizer { + return true + } return true } @@ -92,6 +99,10 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr super.init(coder: aDecoder) configure() } + + deinit { + displayLink?.invalidate() + } private func configure() { let viewController = configureTimelineController(date: Date()) @@ -101,6 +112,8 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr addSubview(pagingViewController.view!) addGestureRecognizer(panGestureRecognizer) panGestureRecognizer.delegate = self + addGestureRecognizer(pinchRecognizer) + pinchRecognizer.delegate = self } public func updateStyle(_ newStyle: TimelineStyle) { @@ -148,6 +161,7 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr let controller = TimelineContainerController() updateStyleOfTimelineContainer(controller: controller) let timeline = controller.timeline + timeline.style.pointsPerMinute = style.pointsPerMinute timeline.longPressGestureRecognizer.addTarget(self, action: #selector(timelineDidLongPress(_:))) timeline.delegate = self timeline.calendar = calendar @@ -214,6 +228,15 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr /// Tag of the last used resize handle private var resizeHandleTag: Int? + /// Pinch to zoom management + private var initialPointsPerMinute: CGFloat = 0 + private var anchorDate: Date = .init() + private var anchorScreenY: CGFloat = 0 + private var displayLink: CADisplayLink? + private var relayoutPending = false // ← add + private var pinchActive = false // ← add + private var pendingScaleChange: CGFloat = 1 // ← accumulate scale changes + /// Creates an EventView and places it on the Timeline /// - Parameter event: the EventDescriptor based on which an EventView will be placed on the Timeline /// - Parameter animated: if true, CalendarKit animates event creation @@ -307,7 +330,7 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr suggestedEventFrame.size.height += diff.y } let minimumMinutesEventDurationWhileEditing = Double(style.minimumEventDurationInMinutesWhileEditing) - let minimumEventHeight = minimumMinutesEventDurationWhileEditing * style.verticalDiff / 60 + let minimumEventHeight = minimumMinutesEventDurationWhileEditing * Double(style.pointsPerMinute) let suggestedEventHeight = suggestedEventFrame.size.height if suggestedEventHeight > minimumEventHeight { @@ -522,4 +545,101 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr public func timelineView(_ timelineView: TimelineView, didLongPress event: EventView) { delegate?.timelinePagerDidLongPressEventView(event) } + + // MARK: - Pinch to Zoom Implementation + @objc private func handlePinch(_ r: UIPinchGestureRecognizer) { + guard let tl = currentTimeline?.timeline, let state else { return } + + switch r.state { + + case .began: + pinchActive = true + setAllTimelines(disableAnimations: true) + startDisplayLink() + initialPointsPerMinute = style.pointsPerMinute + anchorScreenY = r.location(in: tl).y + anchorDate = tl.yToDate(anchorScreenY) + pendingScaleChange = 1 + + case .changed: + guard r.numberOfTouches == 2 else { return } + + let scaleChange = r.scale + guard abs(scaleChange - 1) > 0.001 else { return } + r.scale = 1 + + pendingScaleChange *= scaleChange + relayoutPending = true + + default: + pinchActive = false + stopDisplayLink() + if relayoutPending { + relayoutVisibleTimelines() + relayoutPending = false + } + setAllTimelines(disableAnimations: false) + r.scale = 1 + } + } + + private func relayoutVisibleTimelines() { + pagingViewController.children.forEach { vc in + guard let c = vc as? TimelineContainerController else { return } + c.timeline.style.pointsPerMinute = style.pointsPerMinute + c.timeline.setNeedsLayout() + c.timeline.layoutIfNeeded() + c.container.contentSize.height = c.timeline.fullHeight + } + } + + private func setAllTimelines(disableAnimations: Bool) { + CATransaction.setDisableActions(disableAnimations) + pagingViewController.children + .compactMap { $0 as? TimelineContainerController } + .forEach { $0.timeline.layer.actions = disableAnimations ? ["position": NSNull()] : [:] } + CATransaction.commit() + } + + // MARK: - CADisplayLink Management + @objc private func displayLinkTick() { + guard relayoutPending else { return } + relayoutPending = false + + guard let tl = currentTimeline?.timeline, + let container = currentTimeline?.container, + let state else { return } + + // Ensure timeline is always at least as tall as the scroll view + // This prevents zooming out so far that there's empty space below the timeline + let containerHeight = max(container.bounds.height, 100) // Safety minimum + let minimumPPMForContainer = containerHeight / (24 * 60) // 24 hours * 60 minutes + let effectiveMinimum = max(style.minimumPointsPerMinute, minimumPPMForContainer) + + let newPPM = (style.pointsPerMinute * pendingScaleChange) + .clamped(to: effectiveMinimum...style.maximumPointsPerMinute) + pendingScaleChange = 1 // reset + + guard newPPM != style.pointsPerMinute else { return } + + let yBefore = tl.dateToY(anchorDate) + + style.pointsPerMinute = newPPM + state.pointsPerMinute = newPPM + relayoutVisibleTimelines() // *all* pages, single frame + + let yAfter = tl.dateToY(anchorDate) + currentTimeline?.container.contentOffset.y += (yAfter - yBefore) + } + + private func startDisplayLink() { + guard displayLink == nil else { return } + displayLink = CADisplayLink(target: self, selector: #selector(displayLinkTick)) + displayLink?.add(to: .main, forMode: .common) // .common keeps it ticking during gestures + } + + private func stopDisplayLink() { + displayLink?.invalidate() + displayLink = nil + } } diff --git a/Sources/Timeline/TimelineView.swift b/Sources/Timeline/TimelineView.swift index bdd5c1c7..9c09db15 100644 --- a/Sources/Timeline/TimelineView.swift +++ b/Sources/Timeline/TimelineView.swift @@ -87,7 +87,7 @@ public final class TimelineView: UIView { private var horizontalEventInset: Double = 3 public var fullHeight: Double { - style.verticalInset * 2 + style.verticalDiff * 24 + style.verticalInset * 2 + Double(style.pointsPerMinute) * 24 * 60 } public var calendarWidth: Double { @@ -317,7 +317,7 @@ public final class TimelineView: UIView { return bounds.width } }() - let y = style.verticalInset + hourFloat * style.verticalDiff + offset + let y = style.verticalInset + hourFloat * Double(style.pointsPerMinute) * 60 + offset context?.beginPath() context?.move(to: CGPoint(x: xStart, y: y)) context?.addLine(to: CGPoint(x: xEnd, y: y)) @@ -336,7 +336,7 @@ public final class TimelineView: UIView { } return CGRect(x: x, - y: hourFloat * style.verticalDiff + style.verticalInset - 7, + y: hourFloat * Double(style.pointsPerMinute) * 60 + style.verticalInset - 7, width: style.leadingInset - 8, height: fontSize + 2) }() @@ -357,7 +357,7 @@ public final class TimelineView: UIView { x = 2 } - let timeRect = CGRect(x: x, y: hourFloat * style.verticalDiff + style.verticalInset - 7 + style.verticalDiff * (Double(accentedMinute) / 60), + let timeRect = CGRect(x: x, y: hourFloat * Double(style.pointsPerMinute) * 60 + style.verticalInset - 7 + Double(style.pointsPerMinute) * Double(accentedMinute), width: style.leadingInset - 8, height: fontSize + 2) let timeString = NSString(string: ":\(accentedMinute)") @@ -526,20 +526,21 @@ public final class TimelineView: UIView { // Event starting the previous day dayOffset -= 1 } - let fullTimelineHeight = 24 * style.verticalDiff + let fullTimelineHeight = 24 * Double(style.pointsPerMinute) * 60 let hour = component(component: .hour, from: date) let minute = component(component: .minute, from: date) - let hourY = Double(hour) * style.verticalDiff + style.verticalInset - let minuteY = Double(minute) * style.verticalDiff / 60 + let hourY = Double(hour) * Double(style.pointsPerMinute) * 60 + style.verticalInset + let minuteY = Double(minute) * Double(style.pointsPerMinute) return hourY + minuteY + fullTimelineHeight * dayOffset } public func yToDate(_ y: Double) -> Date { let timeValue = y - style.verticalInset - var hour = Int(timeValue / style.verticalDiff) - let fullHourPoints = Double(hour) * style.verticalDiff + let hourHeight = Double(style.pointsPerMinute) * 60 + var hour = Int(timeValue / hourHeight) + let fullHourPoints = Double(hour) * hourHeight let minuteDiff = timeValue - fullHourPoints - let minute = Int(minuteDiff / style.verticalDiff * 60) + let minute = Int(minuteDiff / Double(style.pointsPerMinute)) var dayOffset = 0 if hour > 23 { dayOffset += 1 From 70b023a9c5a67483c658996bd5449c9373c2d18b Mon Sep 17 00:00:00 2001 From: Bogdan Protsenko Date: Sun, 3 Aug 2025 11:58:03 +0700 Subject: [PATCH 2/5] Minor cleanup --- Sources/Timeline/TimelinePagerView.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/Timeline/TimelinePagerView.swift b/Sources/Timeline/TimelinePagerView.swift index 55eece6d..834f2f2b 100644 --- a/Sources/Timeline/TimelinePagerView.swift +++ b/Sources/Timeline/TimelinePagerView.swift @@ -562,6 +562,7 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr pendingScaleChange = 1 case .changed: + // This can be == 1 for in a pinch to zoom gesture guard r.numberOfTouches == 2 else { return } let scaleChange = r.scale @@ -601,7 +602,7 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr CATransaction.commit() } - // MARK: - CADisplayLink Management + // MARK: - Update zoom level @objc private func displayLinkTick() { guard relayoutPending else { return } relayoutPending = false @@ -611,14 +612,13 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr let state else { return } // Ensure timeline is always at least as tall as the scroll view - // This prevents zooming out so far that there's empty space below the timeline - let containerHeight = max(container.bounds.height, 100) // Safety minimum - let minimumPPMForContainer = containerHeight / (24 * 60) // 24 hours * 60 minutes + let containerHeight = max(container.bounds.height, 100) + let minimumPPMForContainer = containerHeight / (24 * 60) let effectiveMinimum = max(style.minimumPointsPerMinute, minimumPPMForContainer) let newPPM = (style.pointsPerMinute * pendingScaleChange) .clamped(to: effectiveMinimum...style.maximumPointsPerMinute) - pendingScaleChange = 1 // reset + pendingScaleChange = 1 guard newPPM != style.pointsPerMinute else { return } @@ -626,7 +626,7 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr style.pointsPerMinute = newPPM state.pointsPerMinute = newPPM - relayoutVisibleTimelines() // *all* pages, single frame + relayoutVisibleTimelines() let yAfter = tl.dateToY(anchorDate) currentTimeline?.container.contentOffset.y += (yAfter - yBefore) @@ -635,7 +635,7 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr private func startDisplayLink() { guard displayLink == nil else { return } displayLink = CADisplayLink(target: self, selector: #selector(displayLinkTick)) - displayLink?.add(to: .main, forMode: .common) // .common keeps it ticking during gestures + displayLink?.add(to: .main, forMode: .common) } private func stopDisplayLink() { From a78bb1d127e7a516056b4ca3c3e158eca96b99c7 Mon Sep 17 00:00:00 2001 From: Bogdan Protsenko Date: Sun, 3 Aug 2025 11:59:12 +0700 Subject: [PATCH 3/5] Minor cleanup --- Sources/Timeline/TimelinePagerView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Timeline/TimelinePagerView.swift b/Sources/Timeline/TimelinePagerView.swift index 834f2f2b..86fa009d 100644 --- a/Sources/Timeline/TimelinePagerView.swift +++ b/Sources/Timeline/TimelinePagerView.swift @@ -233,9 +233,9 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr private var anchorDate: Date = .init() private var anchorScreenY: CGFloat = 0 private var displayLink: CADisplayLink? - private var relayoutPending = false // ← add - private var pinchActive = false // ← add - private var pendingScaleChange: CGFloat = 1 // ← accumulate scale changes + private var relayoutPending = false + private var pinchActive = false + private var pendingScaleChange: CGFloat = 1 /// Creates an EventView and places it on the Timeline /// - Parameter event: the EventDescriptor based on which an EventView will be placed on the Timeline From 001836529d5d54b6eec3ac35147af822afbdc08c Mon Sep 17 00:00:00 2001 From: Bogdan Protsenko Date: Sun, 3 Aug 2025 17:11:00 +0700 Subject: [PATCH 4/5] .build .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3fb6609a..b8236833 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ Pods # SwiftPM Package.resolved .swiftpm +.build \ No newline at end of file From c374e32f68aad9c503a349984825df14403da36c Mon Sep 17 00:00:00 2001 From: Bogdan Protsenko Date: Sun, 3 Aug 2025 17:20:55 +0700 Subject: [PATCH 5/5] Update edited event size/position during zoom --- Sources/Timeline/TimelinePagerView.swift | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Sources/Timeline/TimelinePagerView.swift b/Sources/Timeline/TimelinePagerView.swift index 86fa009d..61025999 100644 --- a/Sources/Timeline/TimelinePagerView.swift +++ b/Sources/Timeline/TimelinePagerView.swift @@ -630,6 +630,31 @@ public final class TimelinePagerView: UIView, UIGestureRecognizerDelegate, UIScr let yAfter = tl.dateToY(anchorDate) currentTimeline?.container.contentOffset.y += (yAfter - yBefore) + + updateEditedEventViewForZoom() + } + + private func updateEditedEventViewForZoom() { + guard let editedEventView = editedEventView, + let descriptor = editedEventView.descriptor, + let currentTimeline = currentTimeline else { return } + + let timeline = currentTimeline.timeline + let container = currentTimeline.container + let offset = container.contentOffset.y + + let yStart = timeline.dateToY(descriptor.dateInterval.start) - offset + let yEnd = timeline.dateToY(descriptor.dateInterval.end) - offset + + let rightToLeft = UIView.userInterfaceLayoutDirection(for: semanticContentAttribute) == .rightToLeft + let x = rightToLeft ? 0 : timeline.style.leadingInset + + let newFrame = CGRect(x: x, + y: yStart, + width: timeline.calendarWidth, + height: yEnd - yStart) + + editedEventView.frame = newFrame } private func startDisplayLink() {