Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ Pods
# SwiftPM
Package.resolved
.swiftpm
.build
13 changes: 12 additions & 1 deletion Sources/CalendarStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/DayViewState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
147 changes: 146 additions & 1 deletion Sources/Timeline/TimelinePagerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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())
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
21 changes: 11 additions & 10 deletions Sources/Timeline/TimelineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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)
}()
Expand All @@ -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)")
Expand Down Expand Up @@ -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
Expand Down