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 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..61025999 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 + 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 /// - 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,126 @@ 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: + // This can be == 1 for in a pinch to zoom gesture + 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: - Update zoom level + @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 + 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 + + guard newPPM != style.pointsPerMinute else { return } + + let yBefore = tl.dateToY(anchorDate) + + style.pointsPerMinute = newPPM + state.pointsPerMinute = newPPM + relayoutVisibleTimelines() + + 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() { + guard displayLink == nil else { return } + displayLink = CADisplayLink(target: self, selector: #selector(displayLinkTick)) + displayLink?.add(to: .main, forMode: .common) + } + + 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