diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index fbad19f4a..ea4bd9278 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1080,6 +1080,12 @@ /* Title for the home view's default mode. */ "HOME_VIEW_TITLE_INBOX" = "Signal"; +/* Label for brush button in image editor. */ +"IMAGE_EDITOR_BRUSH_BUTTON" = "Brush"; + +/* Label for crop button in image editor. */ +"IMAGE_EDITOR_CROP_BUTTON" = "Crop"; + /* Call setup status label */ "IN_CALL_CONNECTING" = "Connecting…"; diff --git a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift index ee866096b..baeb1f6cb 100644 --- a/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift +++ b/SignalMessaging/ViewControllers/AttachmentApprovalViewController.swift @@ -947,13 +947,19 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD #if DEBUG if let imageEditorModel = attachmentItem.imageEditorModel, - let imageMediaView = self.mediaMessageView.contentView { + let imageMediaView = mediaMessageView.contentView { + let imageEditorView = ImageEditorView(model: imageEditorModel) - imageMediaView.isUserInteractionEnabled = true - imageMediaView.addSubview(imageEditorView) - imageEditorView.autoPinEdgesToSuperviewEdges() + if imageEditorView.createImageView() { + mediaMessageView.isHidden = true + + imageMediaView.isUserInteractionEnabled = true + mediaMessageView.superview?.addSubview(imageEditorView) + imageEditorView.autoPin(toEdgesOf: mediaMessageView) + imageEditorView.addRedBorder() - imageEditorView.addControls(to: self.mediaMessageView) + imageEditorView.addControls(to: imageEditorView) + } } #endif diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift index e01f41a0c..d77707d26 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorModel.swift @@ -43,6 +43,12 @@ public class ImageEditorItem: NSObject { super.init() } + + public typealias PointConversionFunction = (CGPoint) -> CGPoint + + public func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem { + return ImageEditorItem(itemId: itemId, itemType: itemType) + } } // MARK: - @@ -110,6 +116,17 @@ public class ImageEditorStrokeItem: ImageEditorItem { dstSize: CGSize) -> CGFloat { return CGFloatClamp01(unitStrokeWidth) * min(dstSize.width, dstSize.height) } + + public override func clone(withPointConversionFunction conversion: PointConversionFunction) -> ImageEditorItem { + // TODO: We might want to convert the unitStrokeWidth too. + let convertedUnitSamples = unitSamples.map { (sample) in + conversion(sample) + } + return ImageEditorStrokeItem(itemId: itemId, + color: color, + unitSamples: convertedUnitSamples, + unitStrokeWidth: unitStrokeWidth) + } } // MARK: - @@ -222,6 +239,12 @@ public class OrderedDictionary: NSObject { // as immutable, once configured. public class ImageEditorContents: NSObject { + @objc + public let imagePath: String + + @objc + public let imageSizePixels: CGSize + public typealias ItemMapType = OrderedDictionary // This represents the current state of each item, @@ -229,18 +252,27 @@ public class ImageEditorContents: NSObject { var itemMap = ItemMapType() // Used to create an initial, empty instances of this class. - public override init() { + public init(imagePath: String, + imageSizePixels: CGSize) { + self.imagePath = imagePath + self.imageSizePixels = imageSizePixels } // Used to clone copies of instances of this class. - public init(itemMap: ItemMapType) { + public init(imagePath: String, + imageSizePixels: CGSize, + itemMap: ItemMapType) { + self.imagePath = imagePath + self.imageSizePixels = imageSizePixels self.itemMap = itemMap } // Since the contents are immutable, we only modify copies // made with this method. public func clone() -> ImageEditorContents { - return ImageEditorContents(itemMap: itemMap.clone()) + return ImageEditorContents(imagePath: imagePath, + imageSizePixels: imageSizePixels, + itemMap: itemMap.clone()) } @objc @@ -308,7 +340,13 @@ private class ImageEditorOperation: NSObject { @objc public protocol ImageEditorModelDelegate: class { - func imageEditorModelDidChange() + // Used for large changes to the model, when the entire + // model should be reloaded. + func imageEditorModelDidChange(before: ImageEditorContents, + after: ImageEditorContents) + + // Used for small narrow changes to the model, usually + // to a single item. func imageEditorModelDidChange(changedItemIds: [String]) } @@ -325,7 +363,7 @@ public class ImageEditorModel: NSObject { @objc public let srcImageSizePixels: CGSize - private var contents = ImageEditorContents() + private var contents: ImageEditorContents private var undoStack = [ImageEditorOperation]() private var redoStack = [ImageEditorOperation]() @@ -357,9 +395,17 @@ public class ImageEditorModel: NSObject { } self.srcImageSizePixels = srcImageSizePixels + self.contents = ImageEditorContents(imagePath: srcImagePath, + imageSizePixels: srcImageSizePixels) + super.init() } + @objc + public var currentImagePath: String { + return contents.imagePath + } + @objc public func itemCount() -> Int { return contents.itemCount() @@ -395,10 +441,12 @@ public class ImageEditorModel: NSObject { let redoOperation = ImageEditorOperation(contents: contents) redoStack.append(redoOperation) + let oldContents = self.contents self.contents = undoOperation.contents // We could diff here and yield a more narrow change event. - delegate?.imageEditorModelDidChange() + delegate?.imageEditorModelDidChange(before: oldContents, + after: self.contents) } @objc @@ -411,37 +459,98 @@ public class ImageEditorModel: NSObject { let undoOperation = ImageEditorOperation(contents: contents) undoStack.append(undoOperation) + let oldContents = self.contents self.contents = redoOperation.contents // We could diff here and yield a more narrow change event. - delegate?.imageEditorModelDidChange() + delegate?.imageEditorModelDidChange(before: oldContents, + after: self.contents) } @objc public func append(item: ImageEditorItem) { - performAction({ (newContents) in + performAction({ (oldContents) in + let newContents = oldContents.clone() newContents.append(item: item) + return newContents }, changedItemIds: [item.itemId]) } @objc public func replace(item: ImageEditorItem, suppressUndo: Bool = false) { - performAction({ (newContents) in + performAction({ (oldContents) in + let newContents = oldContents.clone() newContents.replace(item: item) + return newContents }, changedItemIds: [item.itemId], suppressUndo: suppressUndo) } @objc public func remove(item: ImageEditorItem) { - performAction({ (newContents) in + performAction({ (oldContents) in + let newContents = oldContents.clone() newContents.remove(item: item) + return newContents }, changedItemIds: [item.itemId]) } - private func performAction(_ action: (ImageEditorContents) -> Void, - changedItemIds: [String], + @objc + public func crop(unitCropRect: CGRect) { + guard let croppedImage = ImageEditorModel.crop(imagePath: contents.imagePath, + unitCropRect: unitCropRect) else { + owsFailDebug("Could not crop image.") + return + } + // Use PNG for temp files; PNG is lossless. + guard let croppedImageData = UIImagePNGRepresentation(croppedImage) else { + owsFailDebug("Could not convert cropped image to PNG.") + return + } + let croppedImagePath = OWSFileSystem.temporaryFilePath(withFileExtension: "png") + do { + try croppedImageData.write(to: NSURL.fileURL(withPath: croppedImagePath), options: .atomicWrite) + } catch let error as NSError { + owsFailDebug("File write failed: \(error)") + return + } + let croppedImageSizePixels = CGSizeScale(croppedImage.size, croppedImage.scale) + + let left = unitCropRect.origin.x + let right = unitCropRect.origin.x + unitCropRect.size.width + let top = unitCropRect.origin.y + let bottom = unitCropRect.origin.y + unitCropRect.size.height + let conversion: ImageEditorItem.PointConversionFunction = { (point) in + // Convert from the pre-crop unit coordinate system + // to post-crop unit coordinate system using inverse + // lerp. + // + // NOTE: Some post-conversion unit values will _NOT_ + // be clamped. e.g. strokes outside the crop + // are that < 0 or > 1. This is fine. + // We could hypothethically discard any items + // whose bounding box is entirely outside the + // new unit rectangle (e.g. have been completely + // cropped) but it doesn't seem worthwhile. + let converted = CGPoint(x: CGFloatInverseLerp(point.x, left, right), + y: CGFloatInverseLerp(point.y, top, bottom)) + return converted + } + + performAction({ (oldContents) in + let newContents = ImageEditorContents(imagePath: croppedImagePath, + imageSizePixels: croppedImageSizePixels) + for oldItem in oldContents.items() { + let newItem = oldItem.clone(withPointConversionFunction: conversion) + newContents.append(item: newItem) + } + return newContents + }, changedItemIds: nil) + } + + private func performAction(_ action: (ImageEditorContents) -> ImageEditorContents, + changedItemIds: [String]?, suppressUndo: Bool = false) { if !suppressUndo { let undoOperation = ImageEditorOperation(contents: contents) @@ -449,10 +558,69 @@ public class ImageEditorModel: NSObject { redoStack.removeAll() } - let newContents = contents.clone() - action(newContents) + let oldContents = self.contents + let newContents = action(oldContents) contents = newContents - delegate?.imageEditorModelDidChange(changedItemIds: changedItemIds) + if let changedItemIds = changedItemIds { + delegate?.imageEditorModelDidChange(changedItemIds: changedItemIds) + } else { + delegate?.imageEditorModelDidChange(before: oldContents, + after: self.contents) + } + } + + // MARK: - Utilities + + // Returns nil on error. + private class func crop(imagePath: String, + unitCropRect: CGRect) -> UIImage? { + // TODO: Do we want to render off the main thread? + AssertIsOnMainThread() + + guard let srcImage = UIImage(contentsOfFile: imagePath) else { + owsFailDebug("Could not load image") + return nil + } + let srcImageSize = srcImage.size + // Convert from unit coordinates to src image coordinates. + let cropRect = CGRect(x: unitCropRect.origin.x * srcImageSize.width, + y: unitCropRect.origin.y * srcImageSize.height, + width: unitCropRect.size.width * srcImageSize.width, + height: unitCropRect.size.height * srcImageSize.height) + + guard cropRect.origin.x >= 0, + cropRect.origin.y >= 0, + cropRect.origin.x + cropRect.size.width <= srcImageSize.width, + cropRect.origin.y + cropRect.size.height <= srcImageSize.height else { + owsFailDebug("Invalid crop rectangle.") + return nil + } + guard cropRect.size.width > 0, + cropRect.size.height > 0 else { + owsFailDebug("Empty crop rectangle.") + return nil + } + + let hasAlpha = NSData.hasAlpha(forValidImageFilePath: imagePath) + + UIGraphicsBeginImageContextWithOptions(cropRect.size, !hasAlpha, srcImage.scale) + defer { UIGraphicsEndImageContext() } + + guard let context = UIGraphicsGetCurrentContext() else { + owsFailDebug("Could not create output context.") + return nil + } + context.interpolationQuality = .high + + // Draw source image. + let dstFrame = CGRect(origin: CGPointInvert(cropRect.origin), size: srcImageSize) + srcImage.draw(in: dstFrame) + + let dstImage = UIGraphicsGetImageFromCurrentImageContext() + if dstImage == nil { + owsFailDebug("could not generate dst image.") + } + return dstImage } } diff --git a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift index c14a638ee..bc702edab 100644 --- a/SignalMessaging/Views/ImageEditor/ImageEditorView.swift +++ b/SignalMessaging/Views/ImageEditor/ImageEditorView.swift @@ -10,6 +10,13 @@ import UIKit public class ImageEditorView: UIView, ImageEditorModelDelegate { private let model: ImageEditorModel + enum EditorMode: String { + case brush + case crop + } + + private var editorMode = EditorMode.brush + @objc public required init(model: ImageEditorModel) { self.model = model @@ -17,11 +24,6 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { super.init(frame: .zero) model.delegate = self - - self.isUserInteractionEnabled = true - - let anyTouchGesture = ImageEditorGestureRecognizer(target: self, action: #selector(handleTouchGesture(_:))) - self.addGestureRecognizer(anyTouchGesture) } @available(*, unavailable, message: "use other init() instead.") @@ -29,10 +31,78 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { notImplemented() } - // MARK: - Buttons + // MARK: - Views + + private let imageView = UIImageView() + private var imageViewConstraints = [NSLayoutConstraint]() + private let layersView = OWSLayerView() + + @objc + public func createImageView() -> Bool { + self.addSubview(imageView) + + guard updateImageView() else { + return false + } + + layersView.clipsToBounds = true + layersView.layoutCallback = { [weak self] (_) in + self?.updateAllContent() + } + self.addSubview(layersView) + layersView.autoPin(toEdgesOf: imageView) + + self.isUserInteractionEnabled = true + layersView.isUserInteractionEnabled = true + let anyTouchGesture = ImageEditorGestureRecognizer(target: self, action: #selector(handleTouchGesture(_:))) + layersView.addGestureRecognizer(anyTouchGesture) + + return true + } + + @objc + public func updateImageView() -> Bool { + Logger.verbose("") + + guard let image = UIImage(contentsOfFile: model.currentImagePath) else { + owsFailDebug("Could not load image") + return false + } + guard image.size.width > 0 && image.size.height > 0 else { + owsFailDebug("Could not load image") + return false + } + + imageView.image = image + imageView.layer.minificationFilter = kCAFilterTrilinear + imageView.layer.magnificationFilter = kCAFilterTrilinear + let aspectRatio = image.size.width / image.size.height + for constraint in imageViewConstraints { + constraint.autoRemove() + } + imageViewConstraints = applyScaleAspectFitLayout(view: imageView, aspectRatio: aspectRatio) + + return true + } + + private func applyScaleAspectFitLayout(view: UIView, aspectRatio: CGFloat) -> [NSLayoutConstraint] { + // This emulates the behavior of contentMode = .scaleAspectFit using + // iOS auto layout constraints. + // + // This allows ConversationInputToolbar to place the "cancel" button + // in the upper-right hand corner of the preview content. + var constraints = [NSLayoutConstraint]() + constraints.append(contentsOf: view.autoCenterInSuperview()) + constraints.append(view.autoPin(toAspectRatio: aspectRatio)) + constraints.append(view.autoMatch(.width, to: .width, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)) + constraints.append(view.autoMatch(.height, to: .height, of: self, withMultiplier: 1.0, relation: .lessThanOrEqual)) + return constraints + } private let undoButton = UIButton(type: .custom) private let redoButton = UIButton(type: .custom) + private let brushButton = UIButton(type: .custom) + private let cropButton = UIButton(type: .custom) @objc public func addControls(to containerView: UIView) { @@ -44,7 +114,15 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { label: NSLocalizedString("BUTTON_REDO", comment: "Label for redo button."), selector: #selector(didTapRedo(sender:))) - let stackView = UIStackView(arrangedSubviews: [undoButton, redoButton]) + configure(button: brushButton, + label: NSLocalizedString("IMAGE_EDITOR_BRUSH_BUTTON", comment: "Label for brush button in image editor."), + selector: #selector(didTapBrush(sender:))) + + configure(button: cropButton, + label: NSLocalizedString("IMAGE_EDITOR_CROP_BUTTON", comment: "Label for crop button in image editor."), + selector: #selector(didTapCrop(sender:))) + + let stackView = UIStackView(arrangedSubviews: [brushButton, cropButton, undoButton, redoButton]) stackView.axis = .vertical stackView.alignment = .center stackView.spacing = 10 @@ -60,10 +138,9 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { label: String, selector: Selector) { button.setTitle(label, for: .normal) - button.setTitleColor(.white, - for: .normal) - button.setTitleColor(.gray, - for: .disabled) + button.setTitleColor(.white, for: .normal) + button.setTitleColor(.gray, for: .disabled) + button.setTitleColor(UIColor.ows_materialBlue, for: .selected) button.titleLabel?.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight() button.addTarget(self, action: selector, for: .touchUpInside) } @@ -71,6 +148,10 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { private func updateButtons() { undoButton.isEnabled = model.canUndo() redoButton.isEnabled = model.canRedo() +// brushButton.isSelected = editorMode == .brush + brushButton.isEnabled = editorMode != .brush +// cropButton.isSelected = editorMode == .crop + cropButton.isEnabled = editorMode != .crop } // MARK: - Actions @@ -93,12 +174,40 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { model.redo() } + @objc func didTapBrush(sender: UIButton) { + Logger.verbose("") + + editorMode = .brush + updateButtons() + } + + @objc func didTapCrop(sender: UIButton) { + Logger.verbose("") + + editorMode = .crop + updateButtons() + } + + @objc + public func handleTouchGesture(_ gestureRecognizer: UIGestureRecognizer) { + AssertIsOnMainThread() + + switch editorMode { + case .brush: + handleBrushGesture(gestureRecognizer) + case .crop: + handleCropGesture(gestureRecognizer) + } + } + + // MARK: - Brush + // These properties are non-empty while drawing a stroke. private var currentStroke: ImageEditorStrokeItem? private var currentStrokeSamples = [ImageEditorStrokeItem.StrokeSample]() @objc - public func handleTouchGesture(_ gestureRecognizer: UIGestureRecognizer) { + public func handleBrushGesture(_ gestureRecognizer: UIGestureRecognizer) { AssertIsOnMainThread() let removeCurrentStroke = { @@ -109,15 +218,6 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { self.currentStrokeSamples.removeAll() } - let referenceView = self - let unitSampleForGestureLocation = { () -> CGPoint in - // TODO: Smooth touch samples before converting into stroke samples. - let location = gestureRecognizer.location(in: referenceView) - let x = CGFloatClamp01(CGFloatInverseLerp(location.x, 0, referenceView.bounds.width)) - let y = CGFloatClamp01(CGFloatInverseLerp(location.y, 0, referenceView.bounds.height)) - return CGPoint(x: x, y: y) - } - // TODO: Color picker. let strokeColor = UIColor.blue // TODO: Tune stroke width. @@ -127,14 +227,14 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { case .began: removeCurrentStroke() - currentStrokeSamples.append(unitSampleForGestureLocation()) + currentStrokeSamples.append(unitSampleForGestureLocation(gestureRecognizer)) let stroke = ImageEditorStrokeItem(color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth) model.append(item: stroke) currentStroke = stroke case .changed, .ended: - currentStrokeSamples.append(unitSampleForGestureLocation()) + currentStrokeSamples.append(unitSampleForGestureLocation(gestureRecognizer)) guard let lastStroke = self.currentStroke else { owsFailDebug("Missing last stroke.") @@ -158,9 +258,125 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { } } + private func unitSampleForGestureLocation(_ gestureRecognizer: UIGestureRecognizer) -> CGPoint { + let referenceView = layersView + // TODO: Smooth touch samples before converting into stroke samples. + let location = gestureRecognizer.location(in: referenceView) + let x = CGFloatClamp01(CGFloatInverseLerp(location.x, 0, referenceView.bounds.width)) + let y = CGFloatClamp01(CGFloatInverseLerp(location.y, 0, referenceView.bounds.height)) + return CGPoint(x: x, y: y) + } + + // MARK: - Crop + + private var cropStartUnit = CGPoint.zero + private var cropEndUnit = CGPoint.zero + private var cropLayer1 = CAShapeLayer() + private var cropLayer2 = CAShapeLayer() + private var cropLayers: [CAShapeLayer] { + return [cropLayer1, cropLayer2] + } + + @objc + public func handleCropGesture(_ gestureRecognizer: UIGestureRecognizer) { + AssertIsOnMainThread() + + let kCropDashLength: CGFloat = 3 + let cancelCrop = { + for cropLayer in self.cropLayers { + cropLayer.removeFromSuperlayer() + cropLayer.removeAllAnimations() + } + } + let updateCropLayer = { (cropLayer: CAShapeLayer) in + cropLayer.fillColor = nil + cropLayer.lineWidth = 1.0 + cropLayer.lineDashPattern = [NSNumber(value: Double(kCropDashLength)), NSNumber(value: Double(kCropDashLength))] + + let viewSize = self.layersView.bounds.size + cropLayer.frame = CGRect(origin: .zero, size: viewSize) + + // Find the upper-left and bottom-right corners of the + // crop rectangle, in unit coordinates. + let unitMin = CGPointMin(self.cropStartUnit, self.cropEndUnit) + let unitMax = CGPointMax(self.cropStartUnit, self.cropEndUnit) + + let transformSampleToPoint = { (unitSample: CGPoint) -> CGPoint in + return CGPoint(x: viewSize.width * unitSample.x, + y: viewSize.height * unitSample.y) + } + + // Convert from unit coordinates to view coordinates. + let pointMin = transformSampleToPoint(unitMin) + let pointMax = transformSampleToPoint(unitMax) + let cropRect = CGRect(x: pointMin.x, + y: pointMin.y, + width: pointMax.x - pointMin.x, + height: pointMax.y - pointMin.y) + let bezierPath = UIBezierPath(rect: cropRect) + cropLayer.path = bezierPath.cgPath + } + let updateCrop = { + updateCropLayer(self.cropLayer1) + updateCropLayer(self.cropLayer2) + self.cropLayer1.strokeColor = UIColor.white.cgColor + self.cropLayer2.strokeColor = UIColor.black.cgColor + self.cropLayer1.lineDashPhase = 0 + self.cropLayer2.lineDashPhase = self.cropLayer1.lineDashPhase + kCropDashLength + } + let startCrop = { + for cropLayer in self.cropLayers { + self.layersView.layer.addSublayer(cropLayer) + } + + updateCrop() + } + let endCrop = { + updateCrop() + + for cropLayer in self.cropLayers { + cropLayer.removeFromSuperlayer() + cropLayer.removeAllAnimations() + } + + // Find the upper-left and bottom-right corners of the + // crop rectangle, in unit coordinates. + let unitMin = CGPointClamp01(CGPointMin(self.cropStartUnit, self.cropEndUnit)) + let unitMax = CGPointClamp01(CGPointMax(self.cropStartUnit, self.cropEndUnit)) + let unitCropRect = CGRect(x: unitMin.x, + y: unitMin.y, + width: unitMax.x - unitMin.x, + height: unitMax.y - unitMin.y) + self.model.crop(unitCropRect: unitCropRect) + } + + switch gestureRecognizer.state { + case .began: + let unitSample = unitSampleForGestureLocation(gestureRecognizer) + cropStartUnit = unitSample + cropEndUnit = unitSample + startCrop() + + case .changed: + cropEndUnit = unitSampleForGestureLocation(gestureRecognizer) + updateCrop() + case .ended: + cropEndUnit = unitSampleForGestureLocation(gestureRecognizer) + endCrop() + default: + cancelCrop() + } + } + // MARK: - ImageEditorModelDelegate - public func imageEditorModelDidChange() { + public func imageEditorModelDidChange(before: ImageEditorContents, + after: ImageEditorContents) { + + if before.imagePath != after.imagePath { + _ = updateImageView() + } + updateAllContent() updateButtons() @@ -210,12 +426,13 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { bounds.height > 0 { for item in model.items() { + let viewSize = layersView.bounds.size guard let layer = ImageEditorView.layerForItem(item: item, - viewSize: bounds.size) else { + viewSize: viewSize) else { continue } - self.layer.addSublayer(layer) + layersView.layer.addSublayer(layer) contentLayerMap[item.itemId] = layer } } @@ -249,12 +466,13 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { } // Item was inserted or updated. + let viewSize = layersView.bounds.size guard let layer = ImageEditorView.layerForItem(item: item, - viewSize: bounds.size) else { + viewSize: viewSize) else { continue } - self.layer.addSublayer(layer) + layersView.layer.addSublayer(layer) contentLayerMap[item.itemId] = layer } } @@ -283,7 +501,7 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { viewSize: CGSize) -> CALayer? { AssertIsOnMainThread() - Logger.verbose("\(item.itemId)") + Logger.verbose("\(item.itemId), viewSize: \(viewSize)") let strokeWidth = ImageEditorStrokeItem.strokeWidth(forUnitStrokeWidth: item.unitStrokeWidth, dstSize: viewSize) @@ -359,6 +577,7 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { shapeLayer.path = bezierPath.cgPath shapeLayer.fillColor = nil shapeLayer.lineCap = kCALineCapRound + shapeLayer.lineJoin = kCALineJoinRound return shapeLayer } @@ -406,9 +625,9 @@ public class ImageEditorView: UIView, ImageEditorModelDelegate { // Render output at same size as source image. let dstSizePixels = model.srcImageSizePixels - let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.srcImagePath) + let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.currentImagePath) - guard let srcImage = UIImage(contentsOfFile: model.srcImagePath) else { + guard let srcImage = UIImage(contentsOfFile: model.currentImagePath) else { owsFailDebug("Could not load src image.") return nil } diff --git a/SignalMessaging/Views/OWSLayerView.swift b/SignalMessaging/Views/OWSLayerView.swift index 7f26a6aaa..b62b7ec19 100644 --- a/SignalMessaging/Views/OWSLayerView.swift +++ b/SignalMessaging/Views/OWSLayerView.swift @@ -6,10 +6,17 @@ import Foundation @objc public class OWSLayerView: UIView { - let layoutCallback: ((UIView) -> Void) + public var layoutCallback: ((UIView) -> Void) @objc - public required init(frame: CGRect, layoutCallback : @escaping (UIView) -> Void) { + public init() { + self.layoutCallback = { (_) in + } + super.init(frame: .zero) + } + + @objc + public init(frame: CGRect, layoutCallback : @escaping (UIView) -> Void) { self.layoutCallback = layoutCallback super.init(frame: frame) } diff --git a/SignalMessaging/categories/UIView+OWS.h b/SignalMessaging/categories/UIView+OWS.h index 2597fe93d..e109c78db 100644 --- a/SignalMessaging/categories/UIView+OWS.h +++ b/SignalMessaging/categories/UIView+OWS.h @@ -2,6 +2,7 @@ // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // +#import "OWSMath.h" #import #import @@ -153,57 +154,6 @@ CGFloat ScaleFromIPhone5(CGFloat iPhone5Value); #pragma mark - Macros -CG_INLINE CGSize CGSizeCeil(CGSize size) -{ - return CGSizeMake((CGFloat)ceil(size.width), (CGFloat)ceil(size.height)); -} - -CG_INLINE CGSize CGSizeFloor(CGSize size) -{ - return CGSizeMake((CGFloat)floor(size.width), (CGFloat)floor(size.height)); -} - -CG_INLINE CGSize CGSizeRound(CGSize size) -{ - return CGSizeMake((CGFloat)round(size.width), (CGFloat)round(size.height)); -} - -CG_INLINE CGSize CGSizeMax(CGSize size1, CGSize size2) -{ - return CGSizeMake(MAX(size1.width, size2.width), MAX(size1.height, size2.height)); -} - -CG_INLINE CGPoint CGPointAdd(CGPoint left, CGPoint right) -{ - return CGPointMake(left.x + right.x, left.y + right.y); -} - -CG_INLINE CGPoint CGPointSubtract(CGPoint left, CGPoint right) -{ - return CGPointMake(left.x - right.x, left.y - right.y); -} - -CG_INLINE CGPoint CGPointScale(CGPoint point, CGFloat factor) -{ - return CGPointMake(point.x * factor, point.y * factor); -} - -CG_INLINE CGFloat CGPointDistance(CGPoint left, CGPoint right) -{ - CGPoint delta = CGPointSubtract(left, right); - return sqrt(delta.x * delta.x + delta.y * delta.y); -} - -CG_INLINE CGSize CGSizeScale(CGSize size, CGFloat factor) -{ - return CGSizeMake(size.width * factor, size.height * factor); -} - -CG_INLINE CGSize CGSizeAdd(CGSize left, CGSize right) -{ - return CGSizeMake(left.width + right.width, left.height + right.height); -} - CGFloat CGHairlineWidth(void); NS_ASSUME_NONNULL_END diff --git a/SignalMessaging/utils/OWSMath.h b/SignalMessaging/utils/OWSMath.h index ea8963d88..d159c9f36 100644 --- a/SignalMessaging/utils/OWSMath.h +++ b/SignalMessaging/utils/OWSMath.h @@ -4,33 +4,111 @@ NS_ASSUME_NONNULL_BEGIN -// TODO: We'll eventually want to promote these into an OWSMath.h header. -static inline CGFloat CGFloatClamp(CGFloat value, CGFloat minValue, CGFloat maxValue) +CG_INLINE CGFloat CGFloatClamp(CGFloat value, CGFloat minValue, CGFloat maxValue) { return MAX(minValue, MIN(maxValue, value)); } -static inline CGFloat CGFloatClamp01(CGFloat value) +CG_INLINE CGFloat CGFloatClamp01(CGFloat value) { return CGFloatClamp(value, 0.f, 1.f); } -static inline CGFloat CGFloatLerp(CGFloat left, CGFloat right, CGFloat alpha) +CG_INLINE CGFloat CGFloatLerp(CGFloat left, CGFloat right, CGFloat alpha) { alpha = CGFloatClamp01(alpha); return (left * (1.f - alpha)) + (right * alpha); } -static inline CGFloat CGFloatInverseLerp(CGFloat value, CGFloat minValue, CGFloat maxValue) +CG_INLINE CGFloat CGFloatInverseLerp(CGFloat value, CGFloat minValue, CGFloat maxValue) { return (value - minValue) / (maxValue - minValue); } // Ceil to an even number -static inline CGFloat CeilEven(CGFloat value) +CG_INLINE CGFloat CeilEven(CGFloat value) { return 2.f * (CGFloat)ceil(value * 0.5f); } +CG_INLINE CGSize CGSizeCeil(CGSize size) +{ + return CGSizeMake((CGFloat)ceil(size.width), (CGFloat)ceil(size.height)); +} + +CG_INLINE CGSize CGSizeFloor(CGSize size) +{ + return CGSizeMake((CGFloat)floor(size.width), (CGFloat)floor(size.height)); +} + +CG_INLINE CGSize CGSizeRound(CGSize size) +{ + return CGSizeMake((CGFloat)round(size.width), (CGFloat)round(size.height)); +} + +CG_INLINE CGSize CGSizeMax(CGSize size1, CGSize size2) +{ + return CGSizeMake(MAX(size1.width, size2.width), MAX(size1.height, size2.height)); +} + +CG_INLINE CGPoint CGPointAdd(CGPoint left, CGPoint right) +{ + return CGPointMake(left.x + right.x, left.y + right.y); +} + +CG_INLINE CGPoint CGPointSubtract(CGPoint left, CGPoint right) +{ + return CGPointMake(left.x - right.x, left.y - right.y); +} + +CG_INLINE CGPoint CGPointScale(CGPoint point, CGFloat factor) +{ + return CGPointMake(point.x * factor, point.y * factor); +} + +CG_INLINE CGFloat CGPointDistance(CGPoint left, CGPoint right) +{ + CGPoint delta = CGPointSubtract(left, right); + return sqrt(delta.x * delta.x + delta.y * delta.y); +} + +CG_INLINE CGPoint CGPointMin(CGPoint left, CGPoint right) +{ + return CGPointMake(MIN(left.x, right.x), MIN(left.y, right.y)); +} + +CG_INLINE CGPoint CGPointMax(CGPoint left, CGPoint right) +{ + return CGPointMake(MAX(left.x, right.x), MAX(left.y, right.y)); +} + +CG_INLINE CGPoint CGPointClamp01(CGPoint point) +{ + return CGPointMake(CGFloatClamp01(point.x), CGFloatClamp01(point.y)); +} + +CG_INLINE CGPoint CGPointInvert(CGPoint point) +{ + return CGPointMake(-point.x, -point.y); +} + +CG_INLINE CGSize CGSizeScale(CGSize size, CGFloat factor) +{ + return CGSizeMake(size.width * factor, size.height * factor); +} + +CG_INLINE CGSize CGSizeAdd(CGSize left, CGSize right) +{ + return CGSizeMake(left.width + right.width, left.height + right.height); +} + +CG_INLINE CGRect CGRectScale(CGRect rect, CGFloat factor) +{ + CGRect result; + result.origin = CGPointScale(rect.origin, factor); + result.size = CGSizeScale(rect.size, factor); + return result; +} + NS_ASSUME_NONNULL_END