// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import UIKit public class EditorTextLayer: CATextLayer { let itemId: String public init(itemId: String) { self.itemId = itemId super.init() } @available(*, unavailable, message: "use other init() instead.") required public init?(coder aDecoder: NSCoder) { notImplemented() } } // MARK: - // A view for previewing an image editor model. @objc public class ImageEditorCanvasView: UIView { private let model: ImageEditorModel private let itemIdsToIgnore: [String] // We want strokes to be rendered above the image and behind text. private static let brushLayerZ: CGFloat = +1 // We want text to be rendered above the image and strokes. private static let textLayerZ: CGFloat = +2 // We leave space for 10k items/layers of each type. private static let zPositionSpacing: CGFloat = 0.0001 @objc public required init(model: ImageEditorModel, itemIdsToIgnore: [String] = []) { self.model = model self.itemIdsToIgnore = itemIdsToIgnore super.init(frame: .zero) model.add(observer: self) } @available(*, unavailable, message: "use other init() instead.") required public init?(coder aDecoder: NSCoder) { notImplemented() } // MARK: - Views // contentView is used to host the layers used to render the content. // // The transform for the content is applied to it. public let contentView = OWSLayerView() // clipView is used to clip the content. It reflects the actual // visible bounds of the "canvas" content. private let clipView = OWSLayerView() private var contentViewConstraints = [NSLayoutConstraint]() private var srcImage: UIImage? private var imageLayer = CALayer() @objc public func configureSubviews() { self.backgroundColor = .clear self.isOpaque = false self.srcImage = loadSrcImage() clipView.clipsToBounds = true clipView.backgroundColor = .clear clipView.isOpaque = false clipView.layoutCallback = { [weak self] (_) in guard let strongSelf = self else { return } strongSelf.updateLayout() } addSubview(clipView) if let srcImage = srcImage { imageLayer.contents = srcImage.cgImage imageLayer.contentsScale = srcImage.scale } contentView.backgroundColor = .clear contentView.isOpaque = false contentView.layer.addSublayer(imageLayer) contentView.layoutCallback = { [weak self] (_) in guard let strongSelf = self else { return } strongSelf.updateAllContent() } clipView.addSubview(contentView) contentView.autoPinEdgesToSuperviewEdges() updateLayout() } public var gestureReferenceView: UIView { return clipView } private func updateLayout() { NSLayoutConstraint.deactivate(contentViewConstraints) contentViewConstraints = ImageEditorCanvasView.updateContentLayout(transform: model.currentTransform(), contentView: clipView) } public class func updateContentLayout(transform: ImageEditorTransform, contentView: UIView) -> [NSLayoutConstraint] { guard let superview = contentView.superview else { owsFailDebug("Content view has no superview.") return [] } let outputSizePixels = transform.outputSizePixels let aspectRatio = outputSizePixels var constraints = superview.applyScaleAspectFitLayout(subview: contentView, aspectRatio: aspectRatio.width / aspectRatio.height) let screenSize = UIScreen.main.bounds.size let maxScreenSize = max(screenSize.width, screenSize.height) let outputSizePoints = CGSize(width: maxScreenSize, height: maxScreenSize) // TODO: Add a "shouldFill" parameter. // let outputSizePoints = CGSizeScale(outputSizePixels, 1.0 / UIScreen.main.scale) NSLayoutConstraint.autoSetPriority(UILayoutPriority.defaultLow) { constraints.append(contentsOf: contentView.autoSetDimensions(to: outputSizePoints)) } return constraints } @objc public func loadSrcImage() -> UIImage? { return ImageEditorCanvasView.loadSrcImage(model: model) } @objc public class func loadSrcImage(model: ImageEditorModel) -> UIImage? { let srcImageData: Data do { let srcImagePath = model.srcImagePath let srcImageUrl = URL(fileURLWithPath: srcImagePath) srcImageData = try Data(contentsOf: srcImageUrl) } catch { owsFailDebug("Couldn't parse srcImageUrl") return nil } // We use this constructor so that we can specify the scale. // // UIImage(contentsOfFile:) will sometimes use device scale. guard let srcImage = UIImage(data: srcImageData, scale: 1.0) else { owsFailDebug("Couldn't load background image.") return nil } // We normalize the image orientation here for the sake // of code simplicity. We could modify the image layer's // transform to handle the normalization, which would // have perf benefits. return srcImage.normalized() } // MARK: - Content var contentLayerMap = [String: CALayer]() internal func updateAllContent() { AssertIsOnMainThread() // Don't animate changes. CATransaction.begin() CATransaction.setDisableActions(true) for layer in contentLayerMap.values { layer.removeFromSuperlayer() } contentLayerMap.removeAll() let viewSize = clipView.bounds.size let transform = model.currentTransform() if viewSize.width > 0, viewSize.height > 0 { applyTransform() updateImageLayer() for item in model.items() { guard !itemIdsToIgnore.contains(item.itemId) else { // Ignore this item. continue } guard let layer = ImageEditorCanvasView.layerForItem(item: item, model: model, transform: transform, viewSize: viewSize) else { continue } contentView.layer.addSublayer(layer) contentLayerMap[item.itemId] = layer } } updateLayout() // Force layout now. setNeedsLayout() layoutIfNeeded() CATransaction.commit() } internal func updateContent(changedItemIds: [String]) { AssertIsOnMainThread() // Don't animate changes. CATransaction.begin() CATransaction.setDisableActions(true) // Remove all changed items. for itemId in changedItemIds { if let layer = contentLayerMap[itemId] { layer.removeFromSuperlayer() } contentLayerMap.removeValue(forKey: itemId) } let viewSize = clipView.bounds.size let transform = model.currentTransform() if viewSize.width > 0, viewSize.height > 0 { applyTransform() updateImageLayer() // Create layers for inserted and updated items. for itemId in changedItemIds { guard let item = model.item(forId: itemId) else { // Item was deleted. continue } // Item was inserted or updated. guard let layer = ImageEditorCanvasView.layerForItem(item: item, model: model, transform: transform, viewSize: viewSize) else { continue } contentView.layer.addSublayer(layer) contentLayerMap[item.itemId] = layer } } CATransaction.commit() } private func applyTransform() { let viewSize = clipView.bounds.size contentView.layer.setAffineTransform(model.currentTransform().affineTransform(viewSize: viewSize)) } private func updateImageLayer() { let viewSize = clipView.bounds.size ImageEditorCanvasView.updateImageLayer(imageLayer: imageLayer, viewSize: viewSize, imageSize: model.srcImageSizePixels, transform: model.currentTransform()) } public class func updateImageLayer(imageLayer: CALayer, viewSize: CGSize, imageSize: CGSize, transform: ImageEditorTransform) { imageLayer.frame = imageFrame(forViewSize: viewSize, imageSize: imageSize, transform: transform) // This is the only place the isFlipped flag is consulted. // We deliberately do _not_ use it in the affine transforms, etc. // so that: // // * It doesn't affect text content & brush strokes. // * To not complicate the other "coordinate system math". let transform = CGAffineTransform.identity.scaledBy(x: transform.isFlipped ? -1 : +1, y: 1) imageLayer.setAffineTransform(transform) } public class func imageFrame(forViewSize viewSize: CGSize, imageSize: CGSize, transform: ImageEditorTransform) -> CGRect { guard viewSize.width > 0, viewSize.height > 0 else { owsFailDebug("Invalid viewSize") return .zero } guard imageSize.width > 0, imageSize.height > 0 else { owsFailDebug("Invalid imageSize") return .zero } // The image content's default size (at scaling = 1) is to fill the output/canvas bounds. // This makes it easier to clamp the scaling to safe values. // The downside is that rotation has the side effect of changing the render size of the // image, which complicates the crop view logic. // // Find the smallest possible image size that will completely fill the output size. // // NOTE: The "bounding box" of the output size that we need to fill needs to // reflect the rotation. let sinValue = abs(sin(transform.rotationRadians)) let cosValue = abs(cos(transform.rotationRadians)) let outputSize = CGSize(width: viewSize.width * cosValue + viewSize.height * sinValue, height: viewSize.width * sinValue + viewSize.height * cosValue) var width = outputSize.width var height = outputSize.width * imageSize.height / imageSize.width if height < outputSize.height { width = outputSize.height * imageSize.width / imageSize.height height = outputSize.height } let imageFrame = CGRect(x: (width - viewSize.width) * -0.5, y: (height - viewSize.height) * -0.5, width: width, height: height) return imageFrame } private class func imageLayerForItem(model: ImageEditorModel, transform: ImageEditorTransform, viewSize: CGSize) -> CALayer? { guard let srcImage = loadSrcImage(model: model) else { owsFailDebug("Could not load src image.") return nil } let imageLayer = CALayer() imageLayer.contents = srcImage.cgImage imageLayer.contentsScale = srcImage.scale updateImageLayer(imageLayer: imageLayer, viewSize: viewSize, imageSize: model.srcImageSizePixels, transform: transform) return imageLayer } private class func layerForItem(item: ImageEditorItem, model: ImageEditorModel, transform: ImageEditorTransform, viewSize: CGSize) -> CALayer? { AssertIsOnMainThread() switch item.itemType { case .test: owsFailDebug("Unexpected test item.") return nil case .stroke: guard let strokeItem = item as? ImageEditorStrokeItem else { owsFailDebug("Item has unexpected type: \(type(of: item)).") return nil } return strokeLayerForItem(item: strokeItem, model: model, transform: transform, viewSize: viewSize) case .text: guard let textItem = item as? ImageEditorTextItem else { owsFailDebug("Item has unexpected type: \(type(of: item)).") return nil } return textLayerForItem(item: textItem, model: model, transform: transform, viewSize: viewSize) } } private class func strokeLayerForItem(item: ImageEditorStrokeItem, model: ImageEditorModel, transform: ImageEditorTransform, viewSize: CGSize) -> CALayer? { AssertIsOnMainThread() let strokeWidth = ImageEditorStrokeItem.strokeWidth(forUnitStrokeWidth: item.unitStrokeWidth, dstSize: viewSize) let unitSamples = item.unitSamples guard unitSamples.count > 0 else { // Not an error; the stroke doesn't have enough samples to render yet. return nil } let shapeLayer = CAShapeLayer() shapeLayer.lineWidth = strokeWidth shapeLayer.strokeColor = item.color.cgColor shapeLayer.frame = CGRect(origin: .zero, size: viewSize) // Stroke samples are specified in "image unit" coordinates, but // need to be rendered in "canvas" coordinates. The imageFrame // is the bounds of the image specified in "canvas" coordinates, // so to transform we can simply convert from image frame units. let imageFrame = ImageEditorCanvasView.imageFrame(forViewSize: viewSize, imageSize: model.srcImageSizePixels, transform: transform) let transformSampleToPoint = { (unitSample: CGPoint) -> CGPoint in return unitSample.fromUnitCoordinates(viewBounds: imageFrame) } // Use bezier curves to smooth stroke. let bezierPath = UIBezierPath() let points = applySmoothing(to: unitSamples.map { (unitSample) in transformSampleToPoint(unitSample) }) var previousForwardVector = CGPoint.zero for index in 0.. CGFloat { let itemIds = model.itemIds() guard let itemIndex = itemIds.firstIndex(of: item.itemId) else { owsFailDebug("Couldn't find index of item.") return zPositionBase } return zPositionBase + CGFloat(itemIndex) * zPositionSpacing } private class func textLayerForItem(item: ImageEditorTextItem, model: ImageEditorModel, transform: ImageEditorTransform, viewSize: CGSize) -> CALayer? { AssertIsOnMainThread() let imageFrame = ImageEditorCanvasView.imageFrame(forViewSize: viewSize, imageSize: model.srcImageSizePixels, transform: transform) // We need to adjust the font size to reflect the current output scale, // using the image width as reference. let fontSize = item.font.pointSize * imageFrame.size.width / item.fontReferenceImageWidth let text = item.text.filterForDisplay ?? "" let attributedString = NSAttributedString(string: text, attributes: [ NSAttributedString.Key.font: item.font.withSize(fontSize), NSAttributedString.Key.foregroundColor: item.color.color ]) let layer = EditorTextLayer(itemId: item.itemId) layer.string = attributedString layer.foregroundColor = item.color.cgColor layer.font = CGFont(item.font.fontName as CFString) layer.fontSize = fontSize layer.isWrapped = true layer.alignmentMode = CATextLayerAlignmentMode.center // I don't think we need to enable allowsFontSubpixelQuantization // or set truncationMode. // This text needs to be rendered at a scale that reflects: // // * The screen scaling (so that text looks sharp on Retina devices. // * The item's scaling (so that text doesn't become blurry as you make it larger). // * Model transform (so that text doesn't become blurry as you zoom the content). layer.contentsScale = UIScreen.main.scale * item.scaling * transform.scaling // TODO: Min with measured width. let maxWidth = imageFrame.size.width * item.unitWidth let maxSize = CGSize(width: maxWidth, height: CGFloat.greatestFiniteMagnitude) // TODO: Is there a more accurate way to measure text in a CATextLayer? // CoreText? let textBounds = attributedString.boundingRect(with: maxSize, options: [ .usesLineFragmentOrigin, .usesFontLeading ], context: nil) // The text item's center is specified in "image unit" coordinates, but // needs to be rendered in "canvas" coordinates. The imageFrame // is the bounds of the image specified in "canvas" coordinates, // so to transform we can simply convert from image frame units. let centerInCanvas = item.unitCenter.fromUnitCoordinates(viewBounds: imageFrame) let layerSize = textBounds.size.ceil layer.frame = CGRect(origin: CGPoint(x: centerInCanvas.x - layerSize.width * 0.5, y: centerInCanvas.y - layerSize.height * 0.5), size: layerSize) let transform = CGAffineTransform.identity.scaledBy(x: item.scaling, y: item.scaling).rotated(by: item.rotationRadians) layer.setAffineTransform(transform) layer.zPosition = zPositionForItem(item: item, model: model, zPositionBase: textLayerZ) return layer } // We apply more than one kind of smoothing. // // This (simple) smoothing reduces jitter from the touch sensor. private class func applySmoothing(to points: [CGPoint]) -> [CGPoint] { AssertIsOnMainThread() var result = [CGPoint]() for index in 0.. UIImage? { // TODO: Do we want to render off the main thread? AssertIsOnMainThread() // Render output at same size as source image. let dstSizePixels = transform.outputSizePixels let dstScale: CGFloat = 1.0 // The size is specified in pixels, not in points. let viewSize = dstSizePixels let hasAlpha = NSData.hasAlpha(forValidImageFilePath: model.srcImagePath) // We use an UIImageView + UIView.renderAsImage() instead of a CGGraphicsContext // Because CALayer.renderInContext() doesn't honor CALayer properties like frame, // transform, etc. let view = UIView() view.backgroundColor = UIColor.clear view.isOpaque = false view.frame = CGRect(origin: .zero, size: viewSize) // Rendering a UIView to an image will not honor the root image's layer transform. // We therefore use a subview. let contentView = UIView() contentView.backgroundColor = UIColor.clear contentView.isOpaque = false contentView.frame = CGRect(origin: .zero, size: viewSize) view.addSubview(contentView) CATransaction.begin() CATransaction.setDisableActions(true) contentView.layer.setAffineTransform(transform.affineTransform(viewSize: viewSize)) guard let imageLayer = imageLayerForItem(model: model, transform: transform, viewSize: viewSize) else { owsFailDebug("Could not load src image.") return nil } imageLayer.contentsScale = dstScale * transform.scaling contentView.layer.addSublayer(imageLayer) var layers = [CALayer]() for item in model.items() { guard let layer = layerForItem(item: item, model: model, transform: transform, viewSize: viewSize) else { owsFailDebug("Couldn't create layer for item.") continue } layer.contentsScale = dstScale * transform.scaling * item.outputScale() layers.append(layer) } // UIView.renderAsImage() doesn't honor zPosition of layers, // so sort the item layers to ensure they are added in the // correct order. let sortedLayers = layers.sorted(by: { (left, right) -> Bool in return left.zPosition < right.zPosition }) for layer in sortedLayers { contentView.layer.addSublayer(layer) } CATransaction.commit() let image = view.renderAsImage(opaque: !hasAlpha, scale: dstScale) return image } // MARK: - public func textLayer(forLocation point: CGPoint) -> EditorTextLayer? { guard let sublayers = contentView.layer.sublayers else { return nil } // First we build a map of all text layers. var layerMap = [String: EditorTextLayer]() for layer in sublayers { guard let textLayer = layer as? EditorTextLayer else { continue } layerMap[textLayer.itemId] = textLayer } // The layer ordering in the model is authoritative. // Iterate over the layers in _reverse_ order of which they appear // in the model, so that layers "on top" are hit first. for item in model.items().reversed() { guard let textLayer = layerMap[item.itemId] else { // Not a text layer. continue } if textLayer.hitTest(point) != nil { return textLayer } } return nil } // MARK: - Coordinates public class func locationImageUnit(forLocationInView locationInView: CGPoint, viewBounds: CGRect, model: ImageEditorModel, transform: ImageEditorTransform) -> CGPoint { let imageFrame = self.imageFrame(forViewSize: viewBounds.size, imageSize: model.srcImageSizePixels, transform: transform) let affineTransformStart = transform.affineTransform(viewSize: viewBounds.size) let locationInContent = locationInView.minus(viewBounds.center).applyingInverse(affineTransformStart).plus(viewBounds.center) let locationImageUnit = locationInContent.toUnitCoordinates(viewBounds: imageFrame, shouldClamp: false) return locationImageUnit } } // MARK: - extension ImageEditorCanvasView: ImageEditorModelObserver { public func imageEditorModelDidChange(before: ImageEditorContents, after: ImageEditorContents) { updateAllContent() } public func imageEditorModelDidChange(changedItemIds: [String]) { updateContent(changedItemIds: changedItemIds) } }