This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended. To summarise this work: - [x] created a new **Xcode** project; - [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project; - [x] created the `Locations` app and also, its `Libraries` package; - [x] created the `Shared` package to share dependencies between the apps; - [x] added a `Makefile` file and implemented some **environment** and **help** commands. Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Reviewed-on: rock-n-code/deep-linking-assignment#1
302 lines
11 KiB
Swift
302 lines
11 KiB
Swift
import UIKit
|
|
|
|
public class TimelineView: UIView {
|
|
public enum Decoration {
|
|
case doubleDot, singleDot, squiggle
|
|
}
|
|
|
|
public override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
setup()
|
|
}
|
|
|
|
public required init?(coder aDecoder: NSCoder) {
|
|
super.init(coder: aDecoder)
|
|
setup()
|
|
}
|
|
|
|
open func setup() {
|
|
}
|
|
|
|
public var decoration: Decoration = .doubleDot {
|
|
didSet {
|
|
guard oldValue != decoration else {
|
|
return
|
|
}
|
|
|
|
switch decoration {
|
|
case .squiggle:
|
|
innerDotShapeLayer.removeFromSuperlayer()
|
|
outerDotShapeLayer.removeFromSuperlayer()
|
|
layer.addSublayer(squiggleShapeLayer)
|
|
updateSquiggleCenterPoint()
|
|
case .doubleDot:
|
|
squiggleShapeLayer.removeFromSuperlayer()
|
|
layer.addSublayer(innerDotShapeLayer)
|
|
layer.addSublayer(outerDotShapeLayer)
|
|
case .singleDot:
|
|
squiggleShapeLayer.removeFromSuperlayer()
|
|
layer.addSublayer(innerDotShapeLayer)
|
|
}
|
|
|
|
setNeedsDisplay()
|
|
}
|
|
}
|
|
public var shouldAnimateDots: Bool = false
|
|
public var minimizeUnanimatedDots: Bool = false
|
|
public var timelineColor: UIColor? = nil {
|
|
didSet {
|
|
refreshColors()
|
|
}
|
|
}
|
|
private var color: CGColor {
|
|
return timelineColor?.cgColor ?? tintColor.cgColor
|
|
}
|
|
|
|
public var verticalLineWidth: CGFloat = 1.0 {
|
|
didSet {
|
|
squiggleShapeLayer.lineWidth = verticalLineWidth
|
|
setNeedsDisplay()
|
|
}
|
|
}
|
|
|
|
public var pauseDotsAnimation: Bool = true {
|
|
didSet {
|
|
displayLink?.isPaused = pauseDotsAnimation
|
|
}
|
|
}
|
|
|
|
private var dotRadius: CGFloat {
|
|
switch decoration {
|
|
case .singleDot: return 7.0
|
|
default: return 9.0
|
|
}
|
|
}
|
|
private let dotMinRadiusNormal: CGFloat = 0.4
|
|
|
|
// At a height of less than 30, (due to rounding) the squiggle's curves don't perfectly align with the straight lines.
|
|
private let squiggleHeight: CGFloat = 30.0
|
|
|
|
public var dotsY: CGFloat = 0 {
|
|
didSet {
|
|
guard shouldAnimateDots == false || decoration == .squiggle else {
|
|
return
|
|
}
|
|
|
|
switch decoration {
|
|
case .doubleDot, .singleDot: updateDotsRadii(to: minimizeUnanimatedDots ? 0.0 : 1.0, at: CGPoint(x: bounds.midX, y: dotsY))
|
|
case .squiggle: updateSquiggleCenterPoint()
|
|
}
|
|
setNeedsDisplay()
|
|
}
|
|
}
|
|
|
|
override public func tintColorDidChange() {
|
|
super.tintColorDidChange()
|
|
refreshColors()
|
|
}
|
|
|
|
override public var backgroundColor: UIColor? {
|
|
didSet {
|
|
outerDotShapeLayer.fillColor = backgroundColor?.cgColor
|
|
squiggleShapeLayer.fillColor = backgroundColor?.cgColor
|
|
}
|
|
}
|
|
|
|
private lazy var outerDotShapeLayer: CAShapeLayer = {
|
|
let shape = CAShapeLayer()
|
|
shape.fillColor = backgroundColor?.cgColor ?? UIColor.white.cgColor
|
|
shape.strokeColor = color
|
|
shape.lineWidth = 1.0
|
|
if decoration == .doubleDot {
|
|
self.layer.addSublayer(shape)
|
|
}
|
|
return shape
|
|
}()
|
|
|
|
private lazy var innerDotShapeLayer: CAShapeLayer = {
|
|
let shape = CAShapeLayer()
|
|
shape.fillColor = color
|
|
shape.strokeColor = color
|
|
shape.lineWidth = 1.0
|
|
if decoration == .doubleDot {
|
|
self.layer.addSublayer(shape)
|
|
}
|
|
return shape
|
|
}()
|
|
|
|
private lazy var squiggleShapeLayer: CAShapeLayer = {
|
|
let shape = CAShapeLayer()
|
|
shape.updateSquiggleLocation(height: squiggleHeight, decorationMidY: dotsY, midX: bounds.midX)
|
|
shape.strokeColor = color
|
|
shape.fillColor = backgroundColor?.cgColor ?? UIColor.white.cgColor
|
|
shape.lineWidth = verticalLineWidth
|
|
if decoration == .squiggle {
|
|
self.layer.addSublayer(shape)
|
|
}
|
|
return shape
|
|
}()
|
|
|
|
private lazy var displayLink: CADisplayLink? = {
|
|
guard decoration == .doubleDot, shouldAnimateDots == true else {
|
|
return nil
|
|
}
|
|
let link = CADisplayLink(target: self, selector: #selector(maybeUpdateDotsRadii))
|
|
link.add(to: RunLoop.main, forMode: RunLoop.Mode.common)
|
|
return link
|
|
}()
|
|
|
|
override public func removeFromSuperview() {
|
|
displayLink?.invalidate()
|
|
displayLink = nil
|
|
super.removeFromSuperview()
|
|
}
|
|
|
|
override public func draw(_ rect: CGRect) {
|
|
super.draw(rect)
|
|
guard let context = UIGraphicsGetCurrentContext() else {
|
|
return
|
|
}
|
|
drawVerticalLine(in: context, rect: rect)
|
|
}
|
|
|
|
public var extendTimelineAboveDot: Bool = true {
|
|
didSet {
|
|
if oldValue != extendTimelineAboveDot {
|
|
setNeedsDisplay()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func drawVerticalLine(in context: CGContext, rect: CGRect) {
|
|
context.setLineWidth(verticalLineWidth)
|
|
context.setStrokeColor(color)
|
|
let lineTopY = extendTimelineAboveDot ? rect.minY : dotsY
|
|
|
|
switch decoration {
|
|
case .doubleDot, .singleDot:
|
|
context.move(to: CGPoint(x: rect.midX, y: lineTopY))
|
|
context.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
|
|
case .squiggle:
|
|
if extendTimelineAboveDot {
|
|
context.move(to: CGPoint(x: rect.midX, y: lineTopY))
|
|
context.addLine(to: CGPoint(x: rect.midX, y: dotsY-squiggleHeight/2))
|
|
}
|
|
context.move(to: CGPoint(x: rect.midX, y: dotsY+squiggleHeight/2))
|
|
context.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
|
|
}
|
|
|
|
context.strokePath()
|
|
}
|
|
|
|
private func refreshColors() {
|
|
outerDotShapeLayer.strokeColor = color
|
|
innerDotShapeLayer.fillColor = color
|
|
innerDotShapeLayer.strokeColor = color
|
|
squiggleShapeLayer.strokeColor = color
|
|
setNeedsDisplay()
|
|
}
|
|
|
|
// Returns CGFloat in range from 0.0 to 1.0. 0.0 indicates dot should be minimized.
|
|
// 1.0 indicates dot should be maximized. Approaches 1.0 as timelineView.dotY
|
|
// approaches vertical center. Approaches 0.0 as timelineView.dotY approaches top
|
|
// or bottom.
|
|
private func dotRadiusNormal(with y:CGFloat, in container:UIView) -> CGFloat {
|
|
let yInContainer = convert(CGPoint(x:0, y:y), to: container).y
|
|
let halfContainerHeight = container.bounds.size.height * 0.5
|
|
return max(0.0, 1.0 - (abs(yInContainer - halfContainerHeight) / halfContainerHeight))
|
|
}
|
|
|
|
private var lastDotRadiusNormal: CGFloat = -1.0 // -1.0 so dots with dotAnimationNormal of "0.0" are visible initially
|
|
|
|
@objc private func maybeUpdateDotsRadii() {
|
|
guard let containerView = window else {
|
|
return
|
|
}
|
|
|
|
// Shift the "full-width dot" point up a bit - otherwise it's in the vertical center of screen.
|
|
let yOffset = containerView.bounds.size.height * 0.15
|
|
|
|
var radiusNormal = dotRadiusNormal(with: dotsY + yOffset, in: containerView)
|
|
|
|
// Reminder: can reduce precision to 1 (significant digit) to reduce how often dot radii are updated.
|
|
let precision: CGFloat = 2
|
|
let roundingNumber = pow(10, precision)
|
|
radiusNormal = (radiusNormal * roundingNumber).rounded(.up) / roundingNumber
|
|
|
|
guard radiusNormal != lastDotRadiusNormal else {
|
|
return
|
|
}
|
|
|
|
updateDotsRadii(to: radiusNormal, at: CGPoint(x: bounds.midX, y: dotsY))
|
|
|
|
// Progressively fade the inner dot when it gets tiny.
|
|
innerDotShapeLayer.opacity = easeInOutQuart(number: Float(radiusNormal))
|
|
|
|
lastDotRadiusNormal = radiusNormal
|
|
}
|
|
|
|
private func updateDotsRadii(to radiusNormal: CGFloat, at center: CGPoint) {
|
|
outerDotShapeLayer.updateDotRadius(dotRadius * max(radiusNormal, dotMinRadiusNormal), center: center)
|
|
innerDotShapeLayer.updateDotRadius(dotRadius * max((radiusNormal - dotMinRadiusNormal), 0.0), center: center)
|
|
}
|
|
|
|
private func updateSquiggleCenterPoint() {
|
|
squiggleShapeLayer.updateSquiggleLocation(height: squiggleHeight, decorationMidY: dotsY, midX: bounds.midX)
|
|
}
|
|
|
|
private func easeInOutQuart(number:Float) -> Float {
|
|
return number < 0.5 ? 8.0 * pow(number, 4) : 1.0 - 8.0 * (number - 1.0) * pow(number, 3)
|
|
}
|
|
}
|
|
|
|
extension CAShapeLayer {
|
|
fileprivate func updateDotRadius(_ radius: CGFloat, center: CGPoint) {
|
|
path = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0.0, endAngle:CGFloat.pi * 2.0, clockwise: true).cgPath
|
|
}
|
|
|
|
fileprivate func updateSquiggleLocation(height: CGFloat, decorationMidY: CGFloat, midX: CGFloat) {
|
|
let startY = decorationMidY - height/2 // squiggle's middle (not top) should be startY
|
|
let topPoint = CGPoint(x: midX, y: startY)
|
|
let quarterOnePoint = CGPoint(x: midX, y: startY + (height*1/4))
|
|
let midPoint = CGPoint(x: midX, y: startY + (height*2/4))
|
|
let quarterThreePoint = CGPoint(x: midX, y: startY + (height*3/4))
|
|
let bottomPoint = CGPoint(x: midX, y: startY + height)
|
|
|
|
/// Math for curves shown/explained on Phab ticket: https://phabricator.wikimedia.org/T258209#6363389
|
|
let eighthOfHeight = height/8
|
|
let circleDiameter = sqrt(2*(eighthOfHeight*eighthOfHeight))
|
|
let radius = circleDiameter/2
|
|
|
|
/// Without this adjustment, the `arcCenter`s are not the true center of circle and the squiggle has some jagged edges.
|
|
let centerAdjustedRadius = radius - 1
|
|
|
|
let arc1Start = CGPoint(x: midX - radius*3, y: topPoint.y + radius*3)
|
|
let arc1Center = CGPoint(x: arc1Start.x + centerAdjustedRadius, y: arc1Start.y + centerAdjustedRadius)
|
|
|
|
let arc2Start = CGPoint(x: midX + radius*1, y: quarterOnePoint.y - radius*1)
|
|
let arc2Center = CGPoint(x: arc2Start.x + centerAdjustedRadius, y: arc2Start.y + centerAdjustedRadius)
|
|
|
|
let arc3Start = CGPoint(x: midX - radius*3, y: midPoint.y + radius*3)
|
|
let arc3Center = CGPoint(x: arc3Start.x + centerAdjustedRadius, y: arc3Start.y + centerAdjustedRadius)
|
|
|
|
let arc4Start = CGPoint(x: midX + radius*1, y: quarterThreePoint.y - radius*1)
|
|
let arc4Center = CGPoint(x: arc4Start.x + centerAdjustedRadius, y: arc4Start.y + centerAdjustedRadius)
|
|
|
|
let squiggle = UIBezierPath()
|
|
let fullCircle = 2 * CGFloat.pi // addArc's angles are in radians, let's make it easier
|
|
squiggle.move(to: topPoint)
|
|
squiggle.addLine(to: arc1Start)
|
|
squiggle.addArc(withCenter: arc1Center, radius: radius, startAngle: fullCircle * 5/8, endAngle: fullCircle * 1/8, clockwise: false)
|
|
squiggle.addLine(to: arc2Start)
|
|
squiggle.addArc(withCenter: arc2Center, radius: radius, startAngle: fullCircle * 5/8, endAngle: fullCircle * 1/8, clockwise: true)
|
|
squiggle.addLine(to: arc3Start)
|
|
squiggle.addArc(withCenter: arc3Center, radius: radius, startAngle: fullCircle * 5/8, endAngle: fullCircle * 1/8, clockwise: false)
|
|
squiggle.addLine(to: arc4Start)
|
|
squiggle.addArc(withCenter: arc4Center, radius: radius, startAngle: fullCircle * 5/8, endAngle: fullCircle * 1/8, clockwise: true)
|
|
squiggle.addLine(to: bottomPoint)
|
|
|
|
path = squiggle.cgPath
|
|
}
|
|
}
|