Cyborg/VectorView.swift (474 lines of code) (raw):

// // Copyright (c) 2018. Uber Technologies // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // #if os(macOS) import AppKit /// Displays a VectorDrawable. open class VectorView: NSView { /// The tint to use for this drawable. /// /// This property is useful primarily for cases where /// the drawable is intended to be reused in many contexts, /// such as icons. In the icon case, you may find it useful to /// set the tint to `(.dst, myColor)`, which will choose /// `myColor` instead of the color specified in the xml. /// /// This property is overridden by the `VectorDrawable`'s `tint` property /// if it has been set. /// /// - note: `tint` is considered external to the VectorDrawable /// and won't be updated when `theme` is set, though it will apply to /// new values provided by the theme. /// It is your responsibility to ensure that changes /// to `theme` also change `tint` if appropriate. public var tint: AndroidTint = (.src, .clear) { didSet { updateLayers() } } /// A source for external values to use to theme the VectorDrawable. public var theme: ThemeProviding { didSet { updateLayers() } } private let resources: ResourceProviding /// The drawable to display. open var drawable: VectorDrawable? { didSet { updateLayers() needsLayout = true invalidateIntrinsicContentSize() } } private var drawableLayers: [CALayer] = [] { didSet { for layer in oldValue { layer.removeFromSuperlayer() } for drawableLayer in drawableLayers { layer?.addSublayer(drawableLayer) } } } private var drawableSize: CGSize = .zero /// Initializer. /// /// - parameter externalValues: A source for external values to use to theme the VectorDrawable. public init(theme: ThemeProviding, resources: ResourceProviding) { self.theme = theme self.resources = resources super.init(frame: .zero) } @available(*, unavailable, message: "NSCoder and Interface Builder is not supported. Use Programmatic layout.") public required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } open override func layout() { super.layout() func makeActualSize(_ drawable: VectorDrawable, at point: CGPoint) { for layer in layer?.sublayers ?? [] { layer.frame = .init(origin: point, size: drawable.intrinsicSize) } } if bounds.size != .zero, let drawable = drawable { func scaleToFill() { for layer in layer?.sublayers ?? [] { layer.frame = bounds } } func scaleToFit() { let size = drawable.intrinsicSize.scaleAspectFit(in: bounds.size) for layer in layer?.sublayers ?? [] { layer.frame = .init(origin: bounds.origin, size: size) } } func center() { makeActualSize(drawable, at: .init(x: bounds.origin.x + bounds.size.width / 2 - drawable.baseWidth / 2, y: bounds.origin.y + bounds.size.height / 2 - drawable.baseHeight / 2)) } switch contentMode { case .scaleAxesIndependently: scaleToFill() case .scaleProportionallyUpOrDown: scaleToFit() case .scaleProportionallyDown: if drawable.intrinsicSize.width > bounds.size.width && drawable.intrinsicSize.height > bounds.size.height { scaleToFit() } else { center() } case .scaleNone: center() @unknown default: // assume it's scaleToFill. scaleToFill() } } } private func updateLayers() { if let drawable = drawable { drawableLayers = drawable.layerRepresentation(in: bounds, using: ExternalValues(resources: resources, theme: theme), tint: drawable.tint ?? tint) updateAutoMirror(to: drawable.autoMirrored) drawableSize = drawable.intrinsicSize } else { drawableLayers = [] drawableSize = .zero } } private func updateAutoMirror(to isMirrored: Bool) { let transform: CATransform3D if isMirrored, case .rightToLeft = userInterfaceLayoutDirection { transform = CATransform3DMakeScale(-1, 1, 1) } else { transform = CATransform3DIdentity } for layer in drawableLayers { layer.transform = transform } } open override var intrinsicContentSize: NSSize { drawableSize } open var contentMode: NSImageScaling = .scaleNone { didSet { updateLayers() } } } #else import UIKit /// Displays a VectorDrawable. open class VectorView: UIView { /// The tint to use for this drawable. /// /// This property is useful primarily for cases where /// the drawable is intended to be reused in many contexts, /// such as icons. In the icon case, you may find it useful to /// set the tint to `(.dst, myColor)`, which will choose /// `myColor` instead of the color specified in the xml. /// /// This property is overridden by the `VectorDrawable`'s `tint` property /// if it has been set. /// /// - note: `tint` is considered external to the VectorDrawable /// and won't be updated when `theme` is set, though it will apply to /// new values provided by the theme. /// It is your responsibility to ensure that changes /// to `theme` also change `tint` if appropriate. public var tint: AndroidTint = (.src, .clear) { didSet { updateLayers() } } /// A source for external values to use to theme the VectorDrawable. public var theme: ThemeProviding { didSet { updateLayers() } } private let resources: ResourceProviding /// The drawable to display. open var drawable: VectorDrawable? { didSet { updateLayers() invalidateIntrinsicContentSize() } } private var drawableLayers: [CALayer] = [] { didSet { for layer in oldValue { layer.removeFromSuperlayer() } for drawableLayer in drawableLayers { layer.addSublayer(drawableLayer) } } } private var drawableSize: CGSize = .zero /// Initializer. /// /// - parameter externalValues: A source for external values to use to theme the VectorDrawable. public init(theme: ThemeProviding, resources: ResourceProviding) { self.theme = theme self.resources = resources super.init(frame: .zero) } @available(*, unavailable, message: "NSCoder and Interface Builder is not supported. Use Programmatic layout.") public required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } open override func layoutSubviews() { super.layoutSubviews() func makeActualSize(_ drawable: VectorDrawable, at point: CGPoint) { for layer in layer.sublayers ?? [] { layer.frame = .init(origin: point, size: drawable.intrinsicSize) } } if bounds.size != .zero, let drawable = drawable { func scaleToFill() { for layer in layer.sublayers ?? [] { layer.frame = bounds } } switch contentMode { case .scaleToFill, .redraw: // redraw behaves the same as scaleToFil in `UIImageView` scaleToFill() case .scaleAspectFit: let size = drawable.intrinsicSize.scaleAspectFit(in: bounds.size) for layer in layer.sublayers ?? [] { layer.frame = .init(origin: bounds.origin, size: size) } case .scaleAspectFill: let size = drawable.intrinsicSize.scaleAspectFill(in: bounds.size) for layer in layer.sublayers ?? [] { layer.frame = .init(origin: .init(x: bounds.origin.x + bounds.size.width / 2 - size.width / 2, y: bounds.origin.y + bounds.size.height / 2 - size.height / 2), size: size) } case .center: makeActualSize(drawable, at: .init(x: bounds.origin.x + bounds.size.width / 2 - drawable.baseWidth / 2, y: bounds.origin.y + bounds.size.height / 2 - drawable.baseHeight / 2)) case .top: makeActualSize(drawable, at: .init(x: bounds.origin.x + bounds.size.width / 2 - drawable.baseWidth / 2, y: 0)) case .bottom: makeActualSize(drawable, at: .init(x: bounds.origin.x + bounds.size.width / 2 - drawable.baseWidth / 2, y: bounds.origin.y + bounds.size.height - drawable.baseHeight)) case .left: makeActualSize(drawable, at: .init(x: 0, y: bounds.origin.y + bounds.size.height / 2 - drawable.baseHeight / 2)) case .right: makeActualSize(drawable, at: .init(x: bounds.origin.x + bounds.size.width - drawable.baseWidth, y: bounds.origin.y + bounds.size.height / 2 - drawable.baseHeight / 2)) case .topLeft: makeActualSize(drawable, at: .init(x: 0, y: 0)) case .topRight: makeActualSize(drawable, at: .init(x: bounds.origin.x + bounds.size.width - drawable.baseWidth, y: 0)) case .bottomLeft: makeActualSize(drawable, at: .init(x: 0, y: bounds.origin.y + bounds.size.height - drawable.baseHeight)) case .bottomRight: makeActualSize(drawable, at: .init(x: bounds.origin.x + bounds.size.width - drawable.baseWidth, y: bounds.origin.y + bounds.size.height - drawable.baseHeight)) @unknown default: // assume it's scaleToFill. scaleToFill() } } } private func updateLayers() { if let drawable = drawable { drawableLayers = drawable.layerRepresentation(in: bounds, using: ExternalValues(resources: resources, theme: theme), tint: drawable.tint ?? tint) updateAutoMirror(to: drawable.autoMirrored) drawableSize = drawable.intrinsicSize } else { drawableLayers = [] drawableSize = .zero } } private func updateAutoMirror(to isMirrored: Bool) { let transform: CATransform3D if isMirrored, case .rightToLeft = effectiveUserInterfaceLayoutDirection { transform = CATransform3DMakeScale(-1, 1, 1) } else { transform = CATransform3DIdentity } for layer in drawableLayers { layer.transform = transform } } open override var semanticContentAttribute: UISemanticContentAttribute { didSet { updateAutoMirror(to: drawable?.autoMirrored ?? false) } } open override var intrinsicContentSize: CGSize { drawableSize } open override var contentMode: UIView.ContentMode { didSet { updateLayers() } } } #endif /// Provides values from a "theme" which /// corresponds to the objects of the same name on Android. You can reimplement the /// Android behavior, or write your own system. public protocol ThemeProviding { /// Gets the color that corresponds to `name` from the Theme. Colors prefixed "?" /// in the VectorDrawable XML file are fetched using this function. /// /// - parameter name: the name of the external value /// - note: You are responsible for providing an appropriate value or crashing /// in the event that you cannot create a color for the name. func colorFromTheme(named name: String) -> UIColor } /// Provides values from "resources" which /// corresponds to the objects of the same name on Android. You can reimplement the /// Android behavior, or write your own system. public protocol ResourceProviding { /// Gets the color that corresponds to `name` from the Resources bundle. Colors prefixed "@" /// in the VectorDrawable XML file are fetched using this function. /// /// - parameter name: the name of the external value /// - note: You are responsible for providing an appropriate value or crashing /// in the event that you cannot create a color for the name. func colorFromResources(named name: String) -> UIColor } struct ExternalValues { let resources: ResourceProviding let theme: ThemeProviding func colorFromTheme(named name: String) -> UIColor { theme.colorFromTheme(named: name) } func colorFromResources(named name: String) -> UIColor { resources.colorFromResources(named: name) } } extension VectorDrawable { func layerRepresentation(in _: CGRect, using externalValues: ExternalValues, tint: AndroidTint) -> [CALayer] { let viewSpace = CGSize(width: viewPortWidth, height: viewPortHeight) return Array( groups .map { group in group.createLayers(using: externalValues, drawableSize: viewSpace, transform: [], tint: tint) } .joined() ) } var intrinsicSize: CGSize { .init(width: baseWidth, height: baseHeight) } } final class ChildResizingLayer: CALayer { override func layoutSublayers() { super.layoutSublayers() mask?.frame = bounds if let sublayers = sublayers { for layer in sublayers { layer.frame = bounds } } } } class ShapeLayer<T>: CAShapeLayer where T: PathCreating { fileprivate let pathData: T fileprivate let pathTransform: [Transform] fileprivate var drawableSize: CGSize { didSet { updateRatio() } } fileprivate var ratio: CGSize = .init(width: 1, height: 1) { didSet { path = pathTransform .apply(to: pathData.createPaths(in: ratio), relativeTo: ratio) } } private func updateRatio() { ratio = CGSize(width: bounds.width / drawableSize.width, height: bounds.height / drawableSize.height) } required override init(layer: Any) { if let typedLayer = layer as? ShapeLayer { pathData = typedLayer.pathData drawableSize = typedLayer.drawableSize pathTransform = typedLayer.pathTransform super.init(layer: layer) } else { fatalError("Core Animation passed a layer of type \(Swift.type(of: layer)), which cannot be used to construct a layer of type \(ShapeLayer.self)") } } init(pathData: T, drawableSize: CGSize, transform: [Transform], name: String?) { self.pathData = pathData self.drawableSize = drawableSize pathTransform = transform super.init() self.name = name } @available(*, unavailable, message: "NSCoder and Interface Builder is not supported. Use Programmatic layout.") required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() if let sublayers = sublayers { for layer in sublayers { layer.frame = bounds } } updateRatio() } } final class ThemeableShapeLayer: ShapeLayer<VectorDrawable.Path> { fileprivate var externalValues: ExternalValues { didSet { updateTheme() } } fileprivate var tint: AndroidTint { didSet { updateTheme() } } private func updateTheme() { pathData.apply(to: self, using: externalValues, tint: tint) } init(pathData: VectorDrawable.Path, externalValues: ExternalValues, drawableSize: CGSize, transform: [Transform], tint: AndroidTint) { self.externalValues = externalValues self.tint = tint super.init(pathData: pathData, drawableSize: drawableSize, transform: transform, name: pathData.name) updateTheme() } required init(layer: Any) { if let typedLayer = layer as? ThemeableShapeLayer { externalValues = typedLayer.externalValues tint = typedLayer.tint super.init(layer: layer) } else { fatalError("Core Animation passed a layer of type \(Swift.type(of: layer)), which cannot be used to construct a layer of type \(ThemeableShapeLayer.self)") } } } final class ThemeableGradientLayer: CAGradientLayer { var gradient: VectorDrawable.Gradient { didSet { updateGradient() } } var externalValues: ExternalValues { didSet { updateGradient() } } init(gradient: VectorDrawable.Gradient, externalValues: ExternalValues) { self.gradient = gradient self.externalValues = externalValues super.init() updateGradient() } required override init(layer: Any) { if let typedLayer = layer as? ThemeableGradientLayer { gradient = typedLayer.gradient externalValues = typedLayer.externalValues super.init(layer: layer) updateGradient() } else { fatalError("Core Animation passed a layer of type \(Swift.type(of: layer)), which cannot be used to construct a layer of type \(ThemeableGradientLayer.self)") } } @available(*, unavailable, message: "NSCoder and Interface Builder is not supported. Use Programmatic layout.") required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSublayers() { super.layoutSublayers() mask?.frame = bounds } private func updateGradient() { gradient.apply(to: self) } } extension CGSize { func scaleAspectFit(in dimensions: CGSize) -> CGSize { scaledToAspect(in: dimensions, with: min) } func scaleAspectFill(in dimensions: CGSize) -> CGSize { scaledToAspect(in: dimensions, with: max) } private func scaledToAspect(in dimensions: CGSize, with function: (CGFloat, CGFloat) -> (CGFloat)) -> CGSize { let heightRatio = dimensions.height / height let widthRatio = dimensions.width / width let ratio = function(heightRatio, widthRatio) return .init(width: width * ratio, height: height * ratio) } }