Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
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
2023-04-08 18:37:13 +00:00

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
}
}