diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index aadd52a2a..69f3722b8 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 3403B95D20EA9527001A1F44 /* OWSContactShareButtonsView.m in Sources */ = {isa = PBXBuildFile; fileRef = 3403B95B20EA9526001A1F44 /* OWSContactShareButtonsView.m */; }; 34074F61203D0CBE004596AE /* OWSSounds.m in Sources */ = {isa = PBXBuildFile; fileRef = 34074F5F203D0CBD004596AE /* OWSSounds.m */; }; 34074F62203D0CBE004596AE /* OWSSounds.h in Headers */ = {isa = PBXBuildFile; fileRef = 34074F60203D0CBE004596AE /* OWSSounds.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 34080EFE2225F96D0087E99F /* ImageEditorPaletteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34080EFD2225F96D0087E99F /* ImageEditorPaletteView.swift */; }; 340B02BA1FA0D6C700F9CFEC /* ConversationViewItemTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */; }; 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */; }; 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87C204DAC8C007AEB0F /* NotificationSettingsViewController.m */; }; @@ -635,6 +636,7 @@ 3403B95C20EA9527001A1F44 /* OWSContactShareButtonsView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSContactShareButtonsView.h; sourceTree = ""; }; 34074F5F203D0CBD004596AE /* OWSSounds.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSSounds.m; sourceTree = ""; }; 34074F60203D0CBE004596AE /* OWSSounds.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSSounds.h; sourceTree = ""; }; + 34080EFD2225F96D0087E99F /* ImageEditorPaletteView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageEditorPaletteView.swift; sourceTree = ""; }; 340B02B61F9FD31800F9CFEC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = translations/he.lproj/Localizable.strings; sourceTree = ""; }; 340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItemTest.m; sourceTree = ""; }; 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NotificationSettingsOptionsViewController.m; sourceTree = ""; }; @@ -1913,6 +1915,7 @@ 34BBC84E220B8A0100857249 /* ImageEditorCropViewController.swift */, 34BBC852220C7AD900857249 /* ImageEditorItem.swift */, 34BEDB0D21C405B0007B0EAE /* ImageEditorModel.swift */, + 34080EFD2225F96D0087E99F /* ImageEditorPaletteView.swift */, 34BBC85C220D19D600857249 /* ImageEditorPanGestureRecognizer.swift */, 34BBC84C220B2D0800857249 /* ImageEditorPinchGestureRecognizer.swift */, 34BBC854220C7ADA00857249 /* ImageEditorStrokeItem.swift */, @@ -3333,6 +3336,7 @@ 34AC09DF211B39B100997B47 /* OWSNavigationController.m in Sources */, 34074F61203D0CBE004596AE /* OWSSounds.m in Sources */, 34BEDB1721C80BCA007B0EAE /* OWSAnyTouchGestureRecognizer.m in Sources */, + 34080EFE2225F96D0087E99F /* ImageEditorPaletteView.swift in Sources */, 34B6A909218B8824007C4606 /* OWS112TypingIndicatorsMigration.swift in Sources */, 4C3E245D21F2B395000AE092 /* DirectionalPanGestureRecognizer.swift in Sources */, 346129B51FD1F7E800532771 /* OWSProfileManager.m in Sources */, diff --git a/Signal/Images.xcassets/image_editor_palette.imageset/Contents.json b/Signal/Images.xcassets/image_editor_palette.imageset/Contents.json new file mode 100644 index 000000000..bf932ac9b --- /dev/null +++ b/Signal/Images.xcassets/image_editor_palette.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Screen Shot 2019-02-26 at 1.57.23 PM.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/image_editor_palette.imageset/Screen Shot 2019-02-26 at 1.57.23 PM.png b/Signal/Images.xcassets/image_editor_palette.imageset/Screen Shot 2019-02-26 at 1.57.23 PM.png new file mode 100644 index 000000000..4b8e2e2bc Binary files /dev/null and b/Signal/Images.xcassets/image_editor_palette.imageset/Screen Shot 2019-02-26 at 1.57.23 PM.png differ diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift new file mode 100644 index 000000000..c648fc2c7 --- /dev/null +++ b/SignalMessaging/Views/ImageEditor/ImageEditorPaletteView.swift @@ -0,0 +1,258 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import UIKit + +public protocol ImageEditorPaletteViewDelegate: class { + func selectedColorDidChange() +} + +// MARK: - + +public class ImageEditorPaletteView: UIView { + + public weak var delegate: ImageEditorPaletteViewDelegate? + + public required init() { + super.init(frame: .zero) + + createContents() + } + + @available(*, unavailable, message: "use other init() instead.") + required public init?(coder aDecoder: NSCoder) { + notImplemented() + } + + // MARK: - Views + + // The actual default is selected later. + public var selectedColor = UIColor.white + + private let imageView = UIImageView() + private let selectionView = UIView() + private let selectionWrapper = OWSLayerView() + private var selectionConstraint: NSLayoutConstraint? + + private func createContents() { + self.backgroundColor = .clear + self.isOpaque = false + + if let image = UIImage(named: "image_editor_palette") { + imageView.image = image + } else { + owsFailDebug("Missing image.") + } + addSubview(imageView) + // We use an invisible margin to expand the hot area of + // this control. + let margin: CGFloat = 8 + // TODO: Review sizing when there's an asset. + imageView.autoSetDimensions(to: CGSize(width: 8, height: 200)) + imageView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: margin, left: margin, bottom: margin, right: margin)) + + selectionWrapper.layoutCallback = { [weak self] (view) in + guard let strongSelf = self else { + return + } + strongSelf.updateState(fireEvent: false) + } + imageView.addSubview(selectionWrapper) + selectionWrapper.autoPinEdgesToSuperviewEdges() + + selectionView.addBorder(with: .white) + selectionView.layer.cornerRadius = selectionSize / 2 + selectionView.autoSetDimensions(to: CGSize(width: selectionSize, height: selectionSize)) + selectionWrapper.addSubview(selectionView) + selectionView.autoHCenterInSuperview() + + isUserInteractionEnabled = true + addGestureRecognizer(PaletteGestureRecognizer(target: self, action: #selector(didTouch))) + + updateState(fireEvent: false) + } + + // 0 = the color at the top of the image is selected. + // 1 = the color at the bottom of the image is selected. + private let selectionSize: CGFloat = 20 + private var selectionAlpha: CGFloat = 0 + + private func selectColor(atLocationY y: CGFloat) { + selectionAlpha = y.inverseLerp(0, imageView.height(), shouldClamp: true) + + updateState(fireEvent: true) + } + + private func updateState(fireEvent: Bool) { + var selectedColor = UIColor.white + if let image = imageView.image, + let cgImage = image.cgImage { + if let imageColor = image.color(atLocation: CGPoint(x: CGFloat(cgImage.width) * 0.5, y: CGFloat(cgImage.height) * selectionAlpha)) { + selectedColor = imageColor + } else { + owsFailDebug("Couldn't determine image color.") + } + } else { + owsFailDebug("Missing image.") + } + self.selectedColor = selectedColor + + selectionView.backgroundColor = selectedColor + + // There must be a better way to pin the selection view's location, + // but I can't find it. + self.selectionConstraint?.autoRemove() + let selectionY = selectionWrapper.height() * selectionAlpha + let selectionConstraint = NSLayoutConstraint(item: selectionView, + attribute: .centerY, relatedBy: .equal, toItem: selectionWrapper, attribute: .top, multiplier: 1, constant: selectionY) + selectionConstraint.autoInstall() + self.selectionConstraint = selectionConstraint + + if fireEvent { + self.delegate?.selectedColorDidChange() + } + } + + // MARK: Events + + @objc + func didTouch(gesture: UIGestureRecognizer) { + Logger.verbose("gesture: \(NSStringForUIGestureRecognizerState(gesture.state))") + switch gesture.state { + case .began, .changed, .ended: + break + default: + return + } + + let location = gesture.location(in: imageView) + selectColor(atLocationY: location.y) + } +} + +// MARK: - + +extension UIImage { + func color(atLocation locationPoints: CGPoint) -> UIColor? { + guard let cgImage = cgImage else { + owsFailDebug("Missing cgImage.") + return nil + } + guard let dataProvider = cgImage.dataProvider else { + owsFailDebug("Could not create dataProvider.") + return nil + } + guard let pixelData = dataProvider.data else { + owsFailDebug("dataProvider has no data.") + return nil + } + let bytesPerPixel: Int = cgImage.bitsPerPixel / 8 + guard bytesPerPixel == 4 else { + owsFailDebug("Invalid bytesPerPixel: \(bytesPerPixel).") + return nil + } + let imageWidth: Int = cgImage.width + let imageHeight: Int = cgImage.height + guard imageWidth > 0, + imageHeight > 0 else { + owsFailDebug("Invalid image size.") + return nil + } + // Convert the location from points to pixels and clamp to the image bounds. + let xPixels: Int = Int(round(locationPoints.x * self.scale)).clamp(0, imageWidth - 1) + let yPixels: Int = Int(round(locationPoints.y * self.scale)).clamp(0, imageHeight - 1) + let dataLength = (pixelData as Data).count + let data: UnsafePointer = CFDataGetBytePtr(pixelData) + let index: Int = (imageWidth * yPixels + xPixels) * bytesPerPixel + guard index >= 0, index < dataLength else { + owsFailDebug("Invalid index.") + return nil + } + + let red = CGFloat(data[index]) / CGFloat(255.0) + let green = CGFloat(data[index+1]) / CGFloat(255.0) + let blue = CGFloat(data[index+2]) / CGFloat(255.0) + let alpha = CGFloat(data[index+3]) / CGFloat(255.0) + + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } +} + +// MARK: - + +// The most permissive GR possible. Accepts any number of touches in any locations. +private class PaletteGestureRecognizer: UIGestureRecognizer { + + @objc + public override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } + + @objc + public override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } + + @objc + public override func shouldRequireFailure(of otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } + + @objc + public override func shouldBeRequiredToFail(by otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } + + @objc + public override func touchesBegan(_ touches: Set, with event: UIEvent) { + handle(event: event) + } + + @objc + public override func touchesMoved(_ touches: Set, with event: UIEvent) { + handle(event: event) + } + + @objc + public override func touchesEnded(_ touches: Set, with event: UIEvent) { + handle(event: event) + } + + @objc + public override func touchesCancelled(_ touches: Set, with event: UIEvent) { + handle(event: event) + } + + private func handle(event: UIEvent) { + var hasValidTouch = false + if let allTouches = event.allTouches { + for touch in allTouches { + switch touch.phase { + case .began, .moved, .stationary: + hasValidTouch = true + default: + break + } + } + } + + if hasValidTouch { + switch self.state { + case .possible: + self.state = .began + case .began, .changed: + self.state = .changed + default: + self.state = .failed + } + } else { + switch self.state { + case .began, .changed: + self.state = .ended + default: + self.state = .failed + } + } + } +} diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift index b0c858dff..d75b5ddb2 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift @@ -23,6 +23,8 @@ public class ImageEditorView: UIView { private let canvasView: ImageEditorCanvasView + private let paletteView = ImageEditorPaletteView() + enum EditorMode: String { // This is the default mode. It is used for interacting with text items. case none @@ -37,8 +39,11 @@ public class ImageEditorView: UIView { } } - private static let defaultColor = UIColor.white - private var currentColor = ImageEditorView.defaultColor + private var currentColor: UIColor { + get { + return paletteView.selectedColor + } + } @objc public required init(model: ImageEditorModel, delegate: ImageEditorViewDelegate) { @@ -71,6 +76,8 @@ public class ImageEditorView: UIView { self.addSubview(canvasView) canvasView.autoPinEdgesToSuperviewEdges() + paletteView.delegate = self + self.isUserInteractionEnabled = true let moveTextGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleMoveTextGesture(_:))) @@ -129,6 +136,7 @@ public class ImageEditorView: UIView { private let newTextButton = UIButton(type: .custom) private var allButtons = [UIButton]() + // TODO: Should this method be private? @objc public func addControls(to containerView: UIView) { configure(button: undoButton, @@ -151,11 +159,7 @@ public class ImageEditorView: UIView { label: "Text", selector: #selector(didTapNewText(sender:))) - let redButton = colorButton(color: UIColor.red) - let whiteButton = colorButton(color: UIColor.white) - let blackButton = colorButton(color: UIColor.black) - - allButtons = [brushButton, cropButton, undoButton, redoButton, newTextButton, redButton, whiteButton, blackButton] + allButtons = [brushButton, cropButton, undoButton, redoButton, newTextButton] let stackView = UIStackView(arrangedSubviews: allButtons) stackView.axis = .vertical @@ -166,6 +170,10 @@ public class ImageEditorView: UIView { stackView.autoAlignAxis(toSuperviewAxis: .horizontal) stackView.autoPinTrailingToSuperviewMargin(withInset: 10) + containerView.addSubview(paletteView) + paletteView.autoVCenterInSuperview() + paletteView.autoPinLeadingToSuperviewMargin(withInset: 10) + updateButtons() } @@ -180,17 +188,6 @@ public class ImageEditorView: UIView { button.addTarget(self, action: selector, for: .touchUpInside) } - private func colorButton(color: UIColor) -> UIButton { - let button = OWSButton { [weak self] in - self?.didSelectColor(color) - } - let size: CGFloat = 20 - let swatch = UIImage(color: color, size: CGSize(width: size, height: size)) - button.setImage(swatch, for: .normal) - button.addBorder(with: UIColor.white) - return button - } - private func updateButtons() { undoButton.isEnabled = model.canUndo() redoButton.isEnabled = model.canRedo() @@ -262,12 +259,6 @@ public class ImageEditorView: UIView { updateButtons() } - @objc func didSelectColor(_ color: UIColor) { - Logger.verbose("") - - currentColor = color - } - // MARK: - Gestures private func updateGestureState() { @@ -679,3 +670,11 @@ extension ImageEditorView: ImageEditorCropViewControllerDelegate { // TODO: } } + +// MARK: - + +extension ImageEditorView: ImageEditorPaletteViewDelegate { + public func selectedColorDidChange() { + // TODO: + } +} diff --git a/SignalMessaging/categories/UIView+OWS.swift b/SignalMessaging/categories/UIView+OWS.swift index bb774a851..b29b58f16 100644 --- a/SignalMessaging/categories/UIView+OWS.swift +++ b/SignalMessaging/categories/UIView+OWS.swift @@ -125,6 +125,14 @@ extension UIView { } public extension CGFloat { + public func clamp(_ minValue: CGFloat, _ maxValue: CGFloat) -> CGFloat { + return CGFloatClamp(self, minValue, maxValue) + } + + public func clamp01(_ minValue: CGFloat, _ maxValue: CGFloat) -> CGFloat { + return CGFloatClamp01(self) + } + // Linear interpolation public func lerp(_ minValue: CGFloat, _ maxValue: CGFloat) -> CGFloat { return CGFloatLerp(minValue, maxValue, self) @@ -139,6 +147,14 @@ public extension CGFloat { public static let halfPi: CGFloat = CGFloat.pi * 0.5 } +public extension Int { + public func clamp(_ minValue: Int, _ maxValue: Int) -> Int { + assert(minValue <= maxValue) + + return Swift.max(minValue, Swift.min(maxValue, self)) + } +} + public extension CGPoint { public func toUnitCoordinates(viewBounds: CGRect, shouldClamp: Bool) -> CGPoint { return CGPoint(x: (x - viewBounds.origin.x).inverseLerp(0, viewBounds.width, shouldClamp: shouldClamp),