// // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalCoreKit public protocol ColorPickerBarViewDelegate: AnyObject { func colorPickerBarView(_ pickerView: ColorPickerBarView, didSelectColor color: ColorPickerBarColor) } // MARK: - // We represent picker colors using this (color, phase) // tuple so that we can consistently restore palette view state. public class ColorPickerBarColor { public let color: UIColor // Colors are chosen from a spectrum of colors. // This unit value represents the location of the // color within that spectrum. let palettePhase: CGFloat var cgColor: CGColor { return color.cgColor } init(color: UIColor, palettePhase: CGFloat) { self.color = color self.palettePhase = palettePhase } class func defaultColor() -> ColorPickerBarColor { return ColorPickerBarColor(color: UIColor(rgbHex: 0xff0000), palettePhase: 1/9) } class var white: ColorPickerBarColor { ColorPickerBarColor(color: .white, palettePhase: 1) } class var black: ColorPickerBarColor { ColorPickerBarColor(color: .black, palettePhase: 0) } static var gradientUIColors: [UIColor] { return [ UIColor(rgbHex: 0x000000), UIColor(rgbHex: 0xff5500), UIColor(rgbHex: 0xffff00), UIColor(rgbHex: 0x00ff00), UIColor(rgbHex: 0x00ffff), UIColor(rgbHex: 0x0000ff), UIColor(rgbHex: 0xff00ff), UIColor(rgbHex: 0xff0000), UIColor(rgbHex: 0xffffff) ] } static var gradientCGColors: [CGColor] { return gradientUIColors.map { $0.cgColor } } static func == (left: ColorPickerBarColor, right: ColorPickerBarColor) -> Bool { return left.palettePhase.fuzzyEquals(right.palettePhase) } } // MARK: - private class ColorPreviewView: OWSLayerView { private static let innerRadius: CGFloat = 32 // 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 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 layoutCallback = { (view) in ColorPreviewView.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(square: ColorPreviewView.innerRadius * 4)) } @available(*, unavailable, message: "use other init() instead.") required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } static func updateLayers(view: UIView, circleLayer: CAShapeLayer, teardropLayer: CAShapeLayer) { let bounds = view.bounds let outerRadius = innerRadius + circleMargin let bottomEdge = CGPoint(x: bounds.center.x, y: bounds.maxY) let teardropTipCenter = bottomEdge.minus(CGPoint(x: 0, y: teardropTipRadius)) let circleCenter = teardropTipCenter.minus(CGPoint(x: 0, y: teardropPointiness + innerRadius)) // 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. Find the length of the first side. let radiusDiff = outerRadius - teardropTipRadius // 3. Find the length of the second side. let tangentLength = (circleCenterDistance.square - radiusDiff.square).squareRoot() let angle = atan2(tangentLength, radiusDiff) let startAngle = angle + .halfPi let endAngle = -angle + .halfPi let teardropPath = UIBezierPath() teardropPath.addArc(withCenter: circleCenter, radius: outerRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true) teardropPath.addArc(withCenter: teardropTipCenter, radius: teardropTipRadius, startAngle: endAngle, endAngle: startAngle, clockwise: true) teardropLayer.path = teardropPath.cgPath teardropLayer.frame = bounds let innerCircleSize = CGSize(square: 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 ColorPickerBarView: UIView { public weak var delegate: ColorPickerBarViewDelegate? public var color: UIColor { selectedValue.color } var selectedValue: ColorPickerBarColor { didSet { updateState() } } init(currentColor: ColorPickerBarColor? = nil) { selectedValue = currentColor ?? ColorPickerBarColor.defaultColor() super.init(frame: .zero) createContents() } @available(*, unavailable, message: "use other init() instead.") required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - Views private let imageView = UIImageView() private static let selectionSize: CGFloat = 22 private static let colorBarWidth: CGFloat = 12 private var selectionView: UIView = { let selectionView = CircleView(diameter: selectionSize) // Use separate view to create border effect because // setting up border on the same view creates a weird glow around the border. let borderView = CircleView(diameter: selectionSize + 1) borderView.layer.borderColor = UIColor.white.cgColor borderView.layer.borderWidth = 2 selectionView.addSubview(borderView) borderView.autoHCenterInSuperview() borderView.autoVCenterInSuperview() return selectionView }() // imageWrapper is used to host the "selection view". private let imageWrapper = OWSLayerView() private var selectionConstraint: NSLayoutConstraint? private let previewView = ColorPreviewView() private func createContents() { isOpaque = false layoutMargins.leading = 0 layoutMargins.trailing = 0 let borderWidth: CGFloat = 2 let image = ColorPickerBarView.buildPaletteGradientImage() imageView.image = image let imageRadius = image.size.height * 0.5 imageView.layer.cornerRadius = imageRadius imageView.clipsToBounds = true addSubview(imageView) imageView.autoSetDimension(.height, toSize: ColorPickerBarView.colorBarWidth) imageView.autoPinEdgesToSuperviewMargins(with: UIEdgeInsets(margin: borderWidth)) // Create "outer border" that doesn't obscure any colors in the strip. let imageViewBorder = PillView() imageViewBorder.layer.borderWidth = borderWidth imageViewBorder.layer.borderColor = UIColor.white.cgColor addSubview(imageViewBorder) imageViewBorder.autoPinEdges(toEdgesOf: imageView, with: UIEdgeInsets(margin: -borderWidth)) imageWrapper.layoutCallback = { [weak self] (_) in guard let strongSelf = self else { return } strongSelf.updateState() } addSubview(imageWrapper) imageWrapper.autoPinEdges(toEdgesOf: imageView) imageWrapper.addSubview(selectionView) selectionView.autoVCenterInSuperview() // There must be a better way to pin the selection view's location, // but I can't find it. let selectionConstraint = NSLayoutConstraint(item: selectionView, attribute: .centerX, relatedBy: .equal, toItem: imageWrapper, attribute: .leading, multiplier: 1, constant: 0) selectionConstraint.autoInstall() self.selectionConstraint = selectionConstraint previewView.isHidden = true addSubview(previewView) previewView.autoPinEdge(.bottom, to: .top, of: imageView, withOffset: -24) previewView.centerXAnchor.constraint(equalTo: selectionView.centerXAnchor).isActive = true addGestureRecognizer(PermissiveGestureRecognizer(target: self, action: #selector(didTouch))) updateState() } private func selectColor(atLocationX locationX: CGFloat) { let palettePhase = locationX.inverseLerp(0, imageView.width, shouldClamp: true) selectedValue = value(for: palettePhase) delegate?.colorPickerBarView(self, didSelectColor: selectedValue) } private func value(for palettePhase: CGFloat) -> ColorPickerBarColor { // We find the color in the palette's gradient that corresponds // to the "phase". // // 0 = top of gradient, first color. // 1 = bottom of gradient, last color. struct GradientSegment { let color0: UIColor let color1: UIColor let palettePhase0: CGFloat let palettePhase1: CGFloat } var segments = [GradientSegment]() let segmentCount = ColorPickerBarColor.gradientUIColors.count - 1 var prevColor: UIColor? for color in ColorPickerBarColor.gradientUIColors { if let color0 = prevColor { let index = CGFloat(segments.count) let color1 = color let palettePhase0: CGFloat = index / CGFloat(segmentCount) let palettePhase1: CGFloat = (index + 1) / CGFloat(segmentCount) segments.append(GradientSegment(color0: color0, color1: color1, palettePhase0: palettePhase0, palettePhase1: palettePhase1)) } prevColor = color } var bestSegment = segments.first for segment in segments { if palettePhase >= segment.palettePhase0 { bestSegment = segment } } guard let segment = bestSegment else { owsFailDebug("Couldn't find matching segment.") return ColorPickerBarColor.defaultColor() } guard palettePhase >= segment.palettePhase0, palettePhase <= segment.palettePhase1 else { owsFailDebug("Invalid segment.") return ColorPickerBarColor.defaultColor() } let segmentPhase = palettePhase.inverseLerp(segment.palettePhase0, segment.palettePhase1).clamp01() // If CAGradientLayer doesn't do naive RGB color interpolation, // this won't be WYSIWYG. let color = segment.color0.blended(with: segment.color1, alpha: segmentPhase) return ColorPickerBarColor(color: color, palettePhase: palettePhase) } private func updateState() { selectionView.backgroundColor = selectedValue.color previewView.selectedColor = selectedValue.color guard let selectionConstraint = selectionConstraint else { owsFailDebug("Missing selectionConstraint.") return } let selectionX = imageWrapper.width * selectedValue.palettePhase selectionConstraint.constant = selectionX } // MARK: Events @objc private func didTouch(gesture: UIGestureRecognizer) { switch gesture.state { case .began, .changed: previewView.isHidden = false case .ended: previewView.isHidden = true default: previewView.isHidden = true return } let location = gesture.location(in: imageView) selectColor(atLocationX: location.x) } private static func buildPaletteGradientImage() -> UIImage { let gradientSize = CGSize(width: UIScreen.main.bounds.width, height: colorBarWidth) let gradientBounds = CGRect(origin: .zero, size: gradientSize) let gradientView = UIView() gradientView.frame = gradientBounds let gradientLayer = CAGradientLayer() gradientView.layer.addSublayer(gradientLayer) gradientLayer.frame = gradientBounds // See: https://github.com/signalapp/Signal-Android/blob/42e94d8f921aba212b1ffebfae4f2590a6f3385a/res/values/arrays.xml#L267-L277 gradientLayer.colors = ColorPickerBarColor.gradientCGColors gradientLayer.startPoint = CGPoint.zero gradientLayer.endPoint = CGPoint(x: 1, y: 0) return gradientView.renderAsImage(opaque: true, scale: UIScreen.main.scale) } } public extension UIColor { func isCloseToColor(_ color: UIColor) -> Bool { return isEqualToColor(color, tolerance: 0.1) } }