diff --git a/SignalMessaging/Views/GalleryRailView.swift b/SignalMessaging/Views/GalleryRailView.swift index 6f797ea6b..4227c108c 100644 --- a/SignalMessaging/Views/GalleryRailView.swift +++ b/SignalMessaging/Views/GalleryRailView.swift @@ -264,13 +264,3 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate { } } } - -public extension CGSize { - var aspectRatio: CGFloat { - guard self.height > 0 else { - return 0 - } - - return self.width / self.height - } -} diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift index 7101b333d..fc0f11032 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift @@ -62,6 +62,118 @@ public class ImageEditorColor: NSObject { // MARK: - +private class PalettePreviewView: OWSLayerView { + + private static let innerRadius: CGFloat = 32 + private static let shadowMargin: CGFloat = 0 + // The distance from the "inner circle" to the "teardrop". + private static let circleMargin: CGFloat = 3 + private static let teardropTipRadius: CGFloat = 4 + private static let teardropPointiness: CGFloat = 12 + + private let teardropColor = UIColor.white + public var selectedColor = UIColor.white { + didSet { + circleLayer.fillColor = selectedColor.cgColor + } + } + + private let circleLayer: CAShapeLayer + private let teardropLayer: CAShapeLayer + + override init() { + let circleLayer = CAShapeLayer() + let teardropLayer = CAShapeLayer() + self.circleLayer = circleLayer + self.teardropLayer = teardropLayer + + super.init() + + circleLayer.strokeColor = nil + teardropLayer.strokeColor = nil + // Layer order matters. + layer.addSublayer(teardropLayer) + layer.addSublayer(circleLayer) + + teardropLayer.fillColor = teardropColor.cgColor + teardropLayer.shadowColor = UIColor.black.cgColor + teardropLayer.shadowRadius = 2.0 + teardropLayer.shadowOpacity = 0.33 + teardropLayer.shadowOffset = .zero + + layoutCallback = { (view) in + PalettePreviewView.updateLayers(view: view, + circleLayer: circleLayer, + teardropLayer: teardropLayer) + } + + // The bounding rect of the teardrop + shadow is non-trivial, so + // we use a generous size that reserves plenty of space. + // + // The size doesn't matter since this view is + // mostly transparent and isn't hot. + autoSetDimensions(to: CGSize(width: PalettePreviewView.innerRadius * 4, + height: PalettePreviewView.innerRadius * 4)) + } + + @available(*, unavailable, message: "use other init() instead.") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } + + static func updateLayers(view: UIView, + circleLayer: CAShapeLayer, + teardropLayer: CAShapeLayer) { + let bounds = view.bounds + let outerRadius = innerRadius + circleMargin + let rightEdge = CGPoint(x: bounds.width, + y: bounds.height * 0.5) + let teardropTipCenter = rightEdge.minus(CGPoint(x: teardropTipRadius + shadowMargin, y: 0)) + let circleCenter = teardropTipCenter.minus(CGPoint(x: teardropPointiness + innerRadius, y: 0)) + + // The "teardrop" shape is bounded by 2 circles, joined by their tangents. + // + // UIBezierPath can be used to draw this using 2 arcs, if we + // have the angle of the tangents. + // + // Finding the tangent between two circles of known distance + radius + // is pretty straightforward. We solve for the right triangle that + // defines the tangents and atan() that triangle to get the angle. + // + // 1. Find the length of the hypotenuse. + let circleCenterDistance = teardropTipCenter.minus(circleCenter).length + // 2. Fine the length of the first side. + let radiusDiff = outerRadius - teardropTipRadius + // 2. Fine the length of the second side. + let tangentLength = (circleCenterDistance.square - radiusDiff.square).squareRoot() + let angle = atan2(tangentLength, radiusDiff) + + let teardropPath = UIBezierPath() + teardropPath.addArc(withCenter: circleCenter, + radius: outerRadius, + startAngle: +angle, + endAngle: -angle, + clockwise: true) + teardropPath.addArc(withCenter: teardropTipCenter, + radius: teardropTipRadius, + startAngle: -angle, + endAngle: +angle, + clockwise: true) + + teardropLayer.path = teardropPath.cgPath + teardropLayer.frame = bounds + + let innerCircleSize = CGSize(width: innerRadius * 2, + height: innerRadius * 2) + let circleFrame = CGRect(origin: circleCenter.minus(innerCircleSize.asPoint.times(0.5)), + size: innerCircleSize) + circleLayer.path = UIBezierPath(ovalIn: circleFrame).cgPath + circleLayer.frame = bounds + } +} + +// MARK: - + public class ImageEditorPaletteView: UIView { public weak var delegate: ImageEditorPaletteViewDelegate? @@ -89,6 +201,8 @@ public class ImageEditorPaletteView: UIView { private let imageWrapper = OWSLayerView() private let shadowView = UIView() private var selectionConstraint: NSLayoutConstraint? + private let previewView = PalettePreviewView() + private var previewConstraint: NSLayoutConstraint? private func createContents() { self.backgroundColor = .clear @@ -140,6 +254,14 @@ public class ImageEditorPaletteView: UIView { selectionConstraint.autoInstall() self.selectionConstraint = selectionConstraint + previewView.isHidden = true + addSubview(previewView) + previewView.autoPinEdge(.trailing, to: .leading, of: imageView, withOffset: -24) + let previewConstraint = NSLayoutConstraint(item: previewView, + attribute: .centerY, relatedBy: .equal, toItem: imageWrapper, attribute: .top, multiplier: 1, constant: 0) + previewConstraint.autoInstall() + self.previewConstraint = previewConstraint + isUserInteractionEnabled = true addGestureRecognizer(PaletteGestureRecognizer(target: self, action: #selector(didTouch))) @@ -208,6 +330,7 @@ public class ImageEditorPaletteView: UIView { private func updateState() { selectionView.backgroundColor = selectedValue.color + previewView.selectedColor = selectedValue.color guard let selectionConstraint = selectionConstraint else { owsFailDebug("Missing selectionConstraint.") @@ -215,6 +338,12 @@ public class ImageEditorPaletteView: UIView { } let selectionY = imageWrapper.height() * selectedValue.palettePhase selectionConstraint.constant = selectionY + + guard let previewConstraint = previewConstraint else { + owsFailDebug("Missing previewConstraint.") + return + } + previewConstraint.constant = selectionY } // MARK: Events @@ -222,9 +351,12 @@ public class ImageEditorPaletteView: UIView { @objc func didTouch(gesture: UIGestureRecognizer) { switch gesture.state { - case .began, .changed, .ended: - break + case .began, .changed: + previewView.isHidden = false + case .ended: + previewView.isHidden = true default: + previewView.isHidden = true return } diff --git a/SignalMessaging/categories/UIView+OWS.swift b/SignalMessaging/categories/UIView+OWS.swift index 4c2d93ec7..a05025f0c 100644 --- a/SignalMessaging/categories/UIView+OWS.swift +++ b/SignalMessaging/categories/UIView+OWS.swift @@ -149,6 +149,10 @@ public extension CGFloat { public func fuzzyEquals(_ other: CGFloat, tolerance: CGFloat = 0.001) -> Bool { return abs(self - other) < tolerance } + + public var square: CGFloat { + return self * self + } } public extension Int { @@ -222,6 +226,25 @@ public extension CGPoint { return (x.fuzzyEquals(other.x, tolerance: tolerance) && y.fuzzyEquals(other.y, tolerance: tolerance)) } + + public static func tan(angle: CGFloat) -> CGPoint { + return CGPoint(x: sin(angle), + y: cos(angle)) + } +} + +public extension CGSize { + var aspectRatio: CGFloat { + guard self.height > 0 else { + return 0 + } + + return self.width / self.height + } + + var asPoint: CGPoint { + return CGPoint(x: width, y: height) + } } public extension CGRect {