Cyborg/VectorDrawable.swift (544 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
public typealias UIColor = NSColor
#else
import UIKit
#endif
/// A Tint mode and color.
public typealias AndroidTint = (BlendMode, UIColor)
enum AndroidUnitOfMeasure: String {
case px
case inch = "in"
case mm
case pt
case dp
case sp
func convertToPoints(from value: Int) -> CGFloat {
let floatValue = CGFloat(value)
// TODO: Implement
return floatValue
}
static var all: [AndroidUnitOfMeasure] = [
.dp,
.px,
.pt,
.inch,
.mm,
.pt,
.sp,
]
}
/// Android Blend Mode. See https://developer.android.com/reference/android/graphics/PorterDuff.Mode
/// for details on the various options.
public enum BlendMode: String, XMLStringRepresentable {
case add
case clear
case darken
case dst
case dstAtop
case dstIn
case dstOut
case dstOver
case lighten
case multiply
case overlay
case screen
case src
case srcAtop
case srcIn
case srcOut
case srcOver
case xor
}
/// Child of a group. Necessary because both Paths and Groups are allowed
/// to be children of Groups, apparently.
protocol GroupChild: AnyObject {
func createLayers(using externalValues: ExternalValues,
drawableSize: CGSize,
transform: [Transform],
tint: AndroidTint) -> [CALayer]
}
/// A VectorDrawable. This can be displayed in a `VectorView`.
///
/// You can set the `tint` and `intrinsicSize` of a `VectorDrawable` by using
/// the `withSize` and `withTint` functions, respectively. `withSizeMultiple` is
/// also available for cases where you want to preserve the aspect ratio of the drawable.
public final class VectorDrawable {
/// The intrinsic width in points.
public let baseWidth: CGFloat
/// The intrinsic height in points.
public let baseHeight: CGFloat
/// The width that all path and group translation coordinates are relative to. Used
/// to resize the VectorDrawable if it's not displayed at `baseWidth`.
public let viewPortWidth: CGFloat
/// The height that all path and group translation coordinates are relative to. Used
/// to resize the VectorDrawable if it's not displayed at `baseHeight`.
public let viewPortHeight: CGFloat
/// The overall alpha to apply to the drawable.
public let baseAlpha: CGFloat
/// Whether the Drawable flips automatically in RTL.
public let autoMirrored: Bool
/// The tint to apply to the drawable.
///
/// This tint color overrides the tint color on the `VectorView` it is
/// displayed in. If this tint is `nil` the `VectorView`'s tint is used.
///
/// - 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 let tint: AndroidTint?
let groups: [GroupChild]
init(baseWidth: CGFloat,
baseHeight: CGFloat,
viewPortWidth: CGFloat,
viewPortHeight: CGFloat,
baseAlpha: CGFloat,
groups: [GroupChild],
autoMirrored: Bool,
tint: AndroidTint? = nil) {
self.baseWidth = baseWidth
self.baseHeight = baseHeight
self.viewPortWidth = viewPortWidth
self.viewPortHeight = viewPortHeight
self.baseAlpha = baseAlpha
self.groups = groups
self.autoMirrored = autoMirrored
self.tint = tint
}
/// Creates a duplicate of the callee with the specified size.
///
/// - parameter size: the size to set the drawable to
/// - returns: a new `VectorDrawable` with the specified size.
public func withSize(_ size: CGSize) -> VectorDrawable {
.init(baseWidth: size.width,
baseHeight: size.height,
viewPortWidth: viewPortWidth,
viewPortHeight: viewPortHeight,
baseAlpha: baseAlpha,
groups: groups,
autoMirrored: autoMirrored,
tint: tint)
}
/// Creates a duplicate of the callee with its base size multiplied by `multiple`.
///
/// - parameter multiple: the number to multiply the starting size by
/// - returns: a new `VectorDrawable` with the specified size.
public func withSizeMultiple(_ multiple: CGFloat) -> VectorDrawable {
withSize(.init(width: baseWidth * multiple,
height: baseHeight * multiple))
}
/// Creates a duplicate of the callee with its tint set to `tint`.
///
/// - parameter tint: the new tint to use
/// - returns: a new `VectorDrawable` with the specified `tint`.
public func withTint(_ tint: AndroidTint) -> VectorDrawable {
.init(baseWidth: baseWidth,
baseHeight: baseHeight,
viewPortWidth: viewPortWidth,
viewPortHeight: viewPortHeight,
baseAlpha: baseAlpha,
groups: groups,
autoMirrored: autoMirrored,
tint: tint)
}
/// Representation of a <group> element from a VectorDrawable document.
public class Group: GroupChild {
/// The name of the group.
public let name: String?
/// The transform to apply to all children of the group.
public let transform: Transform
let children: [GroupChild]
let clipPaths: [ClipPath]
init(name: String?,
transform: Transform,
children: [GroupChild],
clipPaths: [ClipPath]) {
self.name = name
self.transform = transform
self.children = children
self.clipPaths = clipPaths
}
func createLayers(using externalValues: ExternalValues,
drawableSize: CGSize,
transform: [Transform],
tint: AndroidTint) -> [CALayer] {
var clipPathLayers = clipPaths.map { clipPath in
clipPath.createLayer(drawableSize: drawableSize,
transform: transform + [self.transform])
}
let pathLayers = Array(
children.map { child in
child.createLayers(using: externalValues,
drawableSize: drawableSize,
transform: transform + [self.transform],
tint: tint)
}
.joined()
)
if clipPathLayers.isEmpty {
return pathLayers
} else {
let superLayer = ChildResizingLayer()
let maskParent = clipPathLayers.remove(at: 0)
for layer in clipPathLayers {
maskParent.addSublayer(layer)
}
superLayer.mask = maskParent
for child in pathLayers {
superLayer.addSublayer(child)
}
return [superLayer]
}
}
}
public class ClipPath: GroupChild, PathCreating {
public let name: String?
let data: [DrawingCommand]
let fillType: CAShapeLayerFillRule
init(name: String?,
path: [DrawingCommand],
fillType: CAShapeLayerFillRule) {
self.name = name
data = path
self.fillType = fillType
}
func createLayer(drawableSize size: CGSize,
transform: [Transform]) -> CALayer {
let layer = ShapeLayer(pathData: self,
drawableSize: size,
transform: transform,
name: name)
layer.fillColor = UIColor.black.cgColor
return layer
}
func createLayers(using _: ExternalValues,
drawableSize: CGSize,
transform: [Transform],
tint: AndroidTint) -> [CALayer] {
[createLayer(drawableSize: drawableSize,
transform: transform)]
}
}
/// Representation of a <path> element from a VectorDrawable document.
public class Path: GroupChild, PathCreating {
/// The name of the group.
public let name: String?
let fillColor: Color?
let data: [DrawingCommand]
let strokeColor: Color?
let strokeWidth: CGFloat
let strokeAlpha: CGFloat
let fillAlpha: CGFloat
let trimPathStart: CGFloat
let trimPathEnd: CGFloat
let trimPathOffset: CGFloat
let strokeLineCap: LineCap
let strokeLineJoin: LineJoin
let fillType: CAShapeLayerFillRule
let gradient: Gradient?
init(name: String?,
fillColor: Color?,
fillAlpha: CGFloat,
data: [DrawingCommand],
strokeColor: Color?,
strokeWidth: CGFloat,
strokeAlpha: CGFloat,
trimPathStart: CGFloat,
trimPathEnd: CGFloat,
trimPathOffset: CGFloat,
strokeLineCap: LineCap,
strokeLineJoin: LineJoin,
fillType: CAShapeLayerFillRule,
gradient: Gradient?) {
self.name = name
self.data = data
if gradient != nil && fillColor == nil {
// The path will be used as a mask if there's a gradient, so it's necessary to
// ensure that it has a fill color
self.fillColor = .hex(value: .black)
} else {
// TODO: it's not clear if this is the correct behavior,
// or if a vector drawable with a fillColor defined and
// a gradient as the other fill color should be an error.
self.fillColor = fillColor
}
self.strokeAlpha = strokeAlpha
self.strokeColor = strokeColor
self.fillAlpha = fillAlpha
self.trimPathStart = trimPathStart
self.trimPathEnd = trimPathEnd
self.trimPathOffset = trimPathOffset
self.strokeLineCap = strokeLineCap
self.strokeLineJoin = strokeLineJoin
self.fillType = fillType
self.strokeWidth = strokeWidth
self.gradient = gradient
}
func createLayers(using externalValues: ExternalValues,
drawableSize: CGSize,
transform: [Transform],
tint: AndroidTint) -> [CALayer] {
let shapeLayer = ThemeableShapeLayer(pathData: self,
externalValues: externalValues,
drawableSize: drawableSize,
transform: transform,
tint: tint)
if let gradient = gradient {
let gradientLayer = ThemeableGradientLayer(gradient: gradient, externalValues: externalValues)
gradientLayer.mask = shapeLayer
return [gradientLayer]
} else {
return [shapeLayer]
}
}
func apply(to layer: CAShapeLayer,
using externalValues: ExternalValues,
tint: AndroidTint) {
layer.name = name
layer.strokeColor = strokeColor?
.color(from: externalValues)
.multiplyAlpha(with: strokeAlpha)
.tintedWith(tint)
.cgColor
layer.strokeStart = trimPathStart + trimPathOffset
layer.strokeEnd = trimPathEnd + trimPathOffset
layer.fillColor = fillColor?
.color(from: externalValues)
.multiplyAlpha(with: fillAlpha)
.tintedWith(tint)
.cgColor
layer.lineCap = strokeLineCap.intoCoreAnimation
layer.lineJoin = strokeLineJoin.intoCoreAnimation
layer.lineWidth = strokeWidth
layer.fillRule = fillType
}
}
public class Gradient {
let startColor: Color?
let centerColor: Color?
let endColor: Color?
let tileMode: TileMode
let offsets: [Offset]
init(startColor: Color?,
centerColor: Color?,
endColor: Color?,
tileMode: TileMode,
offsets: [Offset]) {
self.startColor = startColor
self.centerColor = centerColor
self.endColor = endColor
self.tileMode = tileMode
self.offsets = offsets
}
struct Offset {
let amount: CGFloat
let color: Color
}
func createLayer(using externalValues: ExternalValues,
drawableSize: CGSize,
transform: [Transform]) -> CALayer {
return ThemeableGradientLayer(gradient: self,
externalValues: externalValues)
}
func apply(to layer: ThemeableGradientLayer) {
layer.colors = offsets.map { (offset) in
let color = offset.color.color(from: layer.externalValues)
return color.cgColor
}
layer.locations = offsets.map { offset in
offset.amount as NSNumber
}
}
}
public class LinearGradient: Gradient {
let start: CGPoint
let end: CGPoint
init(startColor: Color?,
centerColor: Color?,
endColor: Color?,
tileMode: TileMode,
startX: CGFloat,
startY: CGFloat,
endX: CGFloat,
endY: CGFloat,
offsets: [Offset]) {
start = .init(x: startX, y: startY)
end = .init(x: endX, y: endY)
super.init(startColor: startColor,
centerColor: centerColor,
endColor: endColor,
tileMode: tileMode,
offsets: offsets)
}
override func apply(to layer: ThemeableGradientLayer) {
layer.type = .axial
layer.startPoint = start
layer.endPoint = end
super.apply(to: layer)
}
}
public class RadialGradient: Gradient {
let center: CGPoint
let radius: CGFloat
init(startColor: Color?,
centerColor: Color?,
endColor: Color?,
tileMode: TileMode,
centerX: CGFloat,
centerY: CGFloat,
radius: CGFloat,
offsets: [Offset]) {
self.radius = radius
center = .init(x: centerX, y: centerY)
super.init(startColor: startColor,
centerColor: centerColor,
endColor: endColor,
tileMode: tileMode,
offsets: offsets)
}
override func apply(to layer: ThemeableGradientLayer) {
assertionFailure("Radial Gradients are not yet supported")
super.apply(to: layer)
}
}
public class SweepGradient: Gradient {
let center: CGPoint
init(startColor: Color?,
centerColor: Color?,
endColor: Color?,
tileMode: TileMode,
centerX: CGFloat,
centerY: CGFloat,
offsets: [Offset]) {
center = .init(x: centerX, y: centerY)
super.init(startColor: startColor,
centerColor: centerColor,
endColor: endColor,
tileMode: tileMode,
offsets: offsets)
}
override func apply(to layer: ThemeableGradientLayer) {
assertionFailure("Sweep Gradients are not yet supported")
super.apply(to: layer)
}
}
/// An empty VectorDrawable of size zero.
public static let blank = VectorDrawable(baseWidth: 0,
baseHeight: 0,
viewPortWidth: 0,
viewPortHeight: 0,
baseAlpha: 0,
groups: [],
autoMirrored: false)
}
extension UIColor {
func multiplyAlpha(with other: CGFloat) -> UIColor {
withAlphaComponent(alpha * other)
}
var alpha: CGFloat {
// iOS seems to automatically convert this, MacOS does not
#if os(iOS)
var alpha: CGFloat = 0
getRed(nil, green: nil, blue: nil, alpha: &alpha)
return alpha
#else
if let rgb = usingColorSpace(.sRGB) {
var alpha: CGFloat = 0
rgb.getRed(nil, green: nil, blue: nil, alpha: &alpha)
return alpha
} else {
assertionFailure("Couldn't convert a color to rgb.")
return 1
}
#endif
}
var rgba: (CGFloat, CGFloat, CGFloat, CGFloat) {
// iOS seems to automatically convert this, MacOS does not
#if os(iOS)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
getRed(&r, green: &g, blue: &b, alpha: &a)
return (r, g, b, a)
#else
if let rgb = usingColorSpace(.sRGB) {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
rgb.getRed(&r, green: &g, blue: &b, alpha: &a)
return (r, g, b, a)
} else {
assertionFailure("Couldn't convert a color to rgb.")
return (1, 1, 1, 1)
}
#endif
}
func tintedWith(_ tint: AndroidTint) -> UIColor {
let (mode, color) = tint
return mode.blend(src: self, dst: color)
}
}
extension Array where Element == Transform {
func apply(to path: CGPath, relativeTo size: CGSize) -> CGPath {
reduce(path) { path, transform in
transform.apply(to: path, relativeTo: size)
}
}
}
/// A rigid body transformation as specced by VectorDrawable.
public struct Transform {
/// The offset from the origin to apply the rotation from. Specified in relative coordinates.
public let pivot: CGPoint
/// The rotation, in absolute terms.
public let rotation: CGFloat
/// The scale, in absolute terms.
public let scale: CGPoint
/// The translation, in relative terms.
public let translation: CGPoint
/// Intializer.
///
/// - Parameters:
/// - pivot: The offset from the origin to apply the rotation from. Specified in relative coordinates.
/// - rotation: The rotation, in absolute terms.
/// - scale: The scale, in absolute terms.
/// - translation: The translation, in relative terms.
public init(pivot: CGPoint,
rotation: CGFloat,
scale: CGPoint,
translation: CGPoint) {
self.pivot = pivot
self.rotation = rotation
self.scale = scale
self.translation = translation
}
/// The Identity Transform.
public static let identity: Transform = .init(pivot: .zero,
rotation: 0,
scale: CGPoint(x: 1, y: 1),
translation: .zero)
func apply(to path: CGPath, relativeTo size: CGSize) -> CGPath {
let translation = self.translation.times(size.width, size.height)
let pivot = self.pivot.times(size.width, size.height)
let inversePivot = pivot.times(-1, -1)
return path
.apply(transform: CGAffineTransform(scaleX: scale.x, y: scale.y))
.apply(transform: CGAffineTransform(translationX: inversePivot.x, y: inversePivot.y)
.rotated(by: rotation * .pi / 180)
.translatedBy(x: pivot.x, y: pivot.y))
.apply(transform: CGAffineTransform(translationX: translation.x, y: translation.y))
}
}
extension CGPath {
func apply(transform: CGAffineTransform) -> CGPath {
var transform = transform
return copy(using: &transform) ?? self
}
}
protocol PathCreating: AnyObject {
var data: [DrawingCommand] { get }
}
extension PathCreating {
func createPaths(in size: CGSize) -> CGPath {
let path = CGMutablePath()
var context: PriorContext = .zero
for command in data {
context = command.apply(to: path, using: context, in: size)
}
return path
}
}
extension BlendMode {
func blend(src: UIColor, dst: UIColor) -> UIColor {
let (sr, sg, sb, sa) = src.rgba
let (dr, dg, db, da) = dst.rgba
func clamp(_ float: CGFloat) -> CGFloat {
max(0, min(float, 1))
}
func createColor(_ color: (CGFloat, CGFloat) -> CGFloat,
_ alpha: (CGFloat, CGFloat) -> CGFloat) -> UIColor {
UIColor(red: clamp(color(sr, dr)),
green: clamp(color(sg, dg)),
blue: clamp(color(sb, db)),
alpha: clamp(alpha(sa, da)))
}
switch self {
case .add:
return createColor(+, +)
case .clear:
return createColor({ _, _ in 0 }, { _, _ in 0 })
case .darken:
return createColor({ (src: CGFloat, dst: CGFloat) -> CGFloat in (1 - da) * src + (1 - sa) * dst + min(src, dst) },
{ src, dst in src + dst - (src * dst) })
case .dst:
return dst
case .dstAtop:
return createColor({ src, dst in sa * dst + (1 - da) * src }, { _, dst in dst })
case .dstIn:
return createColor({ src, dst in dst * sa }, { src, dst in src * dst })
case .dstOut:
return createColor({ src, dst in (1 - sa) * dst }, { src, dst in (1 - src) * dst })
case .dstOver:
return createColor({ src, dst in dst + (1 - da) * src }, { src, dst in da + ( 1 - da) * sa })
case .lighten:
return createColor({ (src: CGFloat, dst: CGFloat) in ( 1 - da) * src + (1 - sa) * dst + max(src, dst) }, { src, dst in src + dst - src * dst })
case .multiply:
return createColor(*, *)
case .overlay:
return createColor({src, dst in
let first = 2 * src * dst
if first < da {
return first
} else {
return sa * dst - 2 * (da - src) * (sa - dst)
}
}, {src, dst in
src + dst - src * dst
})
case .screen:
return createColor({ (src: CGFloat, dst: CGFloat) in src + dst - src * dst }, { (src: CGFloat, dst: CGFloat) in src + dst - src * dst })
case .src:
return createColor({ src, _ in src }, { src, _ in src })
case .srcAtop:
return createColor({ src, dst in da * src + (1 - sa) * dst }, { _, dst in dst })
case .srcIn:
return createColor({ src, dst in src * dst }, { src, dst in src * dst })
case .srcOut:
return createColor({ src, dst in (1 - da ) * src }, { src, dst in (1 - dst) * src })
case .srcOver:
return createColor({ src, dst in src + (1 - sa ) * dst }, { src, dst in src + (1 - src) * dst })
case .xor:
return createColor({ (src: CGFloat, dst: CGFloat) in (1 - da) * src + (1 - sa) * dst}, { (src: CGFloat, dst: CGFloat) in (1 - dst) * src + (1 - src) * dst})
}
}
}