一起养成写作习惯!这是我参与「日新方案 4 月更文挑战」的第3天,点击查看活动概况。

这个高级SwiftUI动画系列的第五部分将探索Canvas视图。从技术上讲,它不是一个动画视图,但当它与第四部分的 TimelineView 结合时,它带来了许多风趣的或许性,正如这个数字雨的比如所示。

SwiftUI 动画进阶 — Part 5:Canvas

我不得不把这篇文章推迟几周,因为 Canvas 视图有点不稳定。咱们仍然处于测验阶段,所以这是能够预期的。但是,该视图产生的溃散使这里的一些比如无法共享。虽然不是一切的问题都得到了处理,但现在每个比如都能顺利运行。在文章的最后,我将指出我找到的一些处理办法。

一个简略的 Canvas

简而言之,画布Canvas 是一个 SwiftUI 视图,它从一个渲染闭包中取得制作指令。与 SwiftUI API 中的大多数闭包不同,它不是一个视图生成器。这意味着咱们能够运用 Swift 言语且没有任何约束。

该闭包接纳两个参数:上下文context 和 尺度size。上下文运用一个新的 SwiftUI 类型 GraphicsContext,它包含了许多办法和特点,能够让咱们制作任何东西。下面是一个关于怎么运用 Canvas 的基本比如。

SwiftUI 动画进阶 — Part 5:Canvas

struct ContentView: View {
    var body: some View {
        Canvas { context, size in
            let rect = CGRect(origin: .zero, size: size).insetBy(dx: 25, dy: 25)
            // Path
            let path = Path(roundedRect: rect, cornerRadius: 35.0)
            // Gradient
            let gradient = Gradient(colors: [.green, .blue])
            let from = rect.origin
            let to = CGPoint(x: rect.width + from.x, y: rect.height + from.y)
            // Stroke path
            context.stroke(path, with: .color(.blue), lineWidth: 25)
            // Fill path
            context.fill(path, with: .linearGradient(gradient,
                                                     startPoint: from,
                                                     endPoint: to))
        }
    }
}

Canvas初始值设定项还有其他参数(不透明度opaque、色彩形式colorMode和渲染同步rendersAsynchronously)。请参阅苹果的文档以了解更多信息。

图形上下文 – GraphicsContext

GraphicsContext 有许多办法和特点,但我并不计划把这篇文章作为一个参阅,把它们z一一列出。这是一个很长的列表,或许会让人有点不知所措。但是,当我在更新 Companion for SwiftUI app 时,我确实不得不去阅览一切这些办法。这让我有了一个整体的想法。我将测验对现有的东西进行分类,这样你就能得到相同的东西。

  • Drawing Paths
  • Drawing Images and Text
  • Drawing Symbols (aka SwiftUI views)
  • Mutating the Graphics Context
  • Reusing CoreGraphics Code
  • Animating the Canvas
  • Canvas Crashes

途径 – Paths

制作途径的第一件事是创立它。从 SwiftUI 的第一个版别开端,途径能够经过多种办法创立和修改。一些可用的初始化器是:

let path = Path(roundedRect: rect, cornerSize: CGSize(width: 10, height: 50), style: .continuous)
let cgPath = CGPath(ellipseIn: rect, transform: nil)
let path = Path(cgPath)
let path = Path {
    let points: [CGPoint] = [
        .init(x: 10, y: 10),
        .init(x: 0, y: 50),
        .init(x: 100, y: 100),
        .init(x: 100, y: 0),
    ]
    $0.move(to: .zero)
    $0.addLines(points)
}

途径也能够从一个 SwiftUI 形状中创立。Shape 协议有一个途径办法,你能够用它来创立一个 path:

let path = Circle().path(in: rect)

当然,这也适用于自定义形状:

let path = MyCustomShape().path(in: rect)

填充途径

要填充一个途径,请运用 context.fill() 办法:

fill(_ path: Path, with shading: GraphicsContext.Shading, style: FillStyle = FillStyle())

上色shading表示怎么填充形状(用色彩、突变、平铺图画等)。假如你需求指示要运用的款式,请运用FillStyle类型(即偶数奇数/反义特点)。

途径描边 – Stroke

要描画一个途径,请运用这些GraphicsContext办法中的一个:

stroke(_ path: Path, with shading: GraphicsContext.Shading, style: StrokeStyle)
stroke(_ path: Path, with shading: GraphicsContext.Shading, lineWidth: CGFloat = 1)

你能够指定一个shading(色彩、突变等)来表示怎么描画途径。假如你需求指定破折号、线帽、连接等,请运用款式style。别的,你也能够只指定线宽。

关于怎么描边和填充一个形状的完好比如,请看上面的比如(一个简略的 Canvas)。

图片和文本 – Image & Text

图画和文本是运用上下文draw()办法制作的,有两个版别:

draw(image_or_text, at point: CGPoint, anchor: UnitPoint = .center)
draw(image_or_text, in rect: CGRect)

在图画的状况下,第二个draw()版别有一个额外的可选参数,style:

draw(image, in rect: CGRect, style: FillStyle = FillStyle())

在这些元素之一能够被制作之前,它们有必要被解析。经过解析,SwiftUI将考虑到环境(例如,色彩方案、显示分辨率等)。此外,解析这些元素会暴露出一些风趣的特点,这些特点或许会被进一步用于咱们的制作逻辑。例如,解析后的文本会告知咱们指定字体的终究尺度。或许咱们也能够在制作之前改动已解析元素的暗影。要了解更多关于可用的特点和办法,请查看 ResolvedImageResolvedText

运用上下文的resolve()办法从Image中取得ResolvedImage,从Text中取得ResolvedText

解析是可选的,draw()办法也承受ImageText(而不是ResolvedImageResolvedText)。在这种状况下,draw()会主动解析它们。假如你对已解析的特点和办法没有任何用处,这很方便。

在这个比如中,文本被处理了。咱们用它的巨细来计算突变,并用上色shading来运用这种突变:

SwiftUI 动画进阶 — Part 5:Canvas

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            let midPoint = CGPoint(x: size.width/2, y: size.height/2)
            let font = Font.custom("Arial Rounded MT Bold", size: 36)
            var resolved = context.resolve(Text("Hello World!").font(font))
            let start = CGPoint(x: (size.width - resolved.measure(in: size).width) / 2.0, y: 0)
            let end = CGPoint(x: size.width - start.x, y: 0)
            resolved.shading = .linearGradient(Gradient(colors: [.green, .blue]),
                                               startPoint: start,
                                               endPoint: end)
            context.draw(resolved, at: midPoint, anchor: .center)
        }
    }
}

符号 – Symbols

在谈Canvas时,符号Symbols指的只是任何的 SwiftUI。不要与SF符号相混淆,后者是完全不同的东西。Canvas 视图有一种引证 SwiftUI 视图的办法,将其解析为一个符号,然后制作它。

要处理的视图是在ViewBuilder闭包中传递的,如下面的比如所示。为了引证一个视图,它需求被标记为一个唯一的可散列的标识符。请注意,一个被解析的符号能够在Canvas上制作不止一次。

SwiftUI 动画进阶 — Part 5:Canvas

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            let r0 = context.resolveSymbol(id: 0)!
            let r1 = context.resolveSymbol(id: 1)!
            let r2 = context.resolveSymbol(id: 2)!
            context.draw(r0, at: .init(x: 10, y: 10), anchor: .topLeading)
            context.draw(r1, at: .init(x: 30, y: 20), anchor: .topLeading)
            context.draw(r2, at: .init(x: 50, y: 30), anchor: .topLeading)
            context.draw(r0, at: .init(x: 70, y: 40), anchor: .topLeading)
        } symbols: {
            RoundedRectangle(cornerRadius: 10.0).fill(.cyan)
                .frame(width: 100, height: 50)
                .tag(0)
            RoundedRectangle(cornerRadius: 10.0).fill(.blue)
                .frame(width: 100, height: 50)
                .tag(1)
            RoundedRectangle(cornerRadius: 10.0).fill(.indigo)
                .frame(width: 100, height: 50)
                .tag(2)
        }
    }
}

ViewBuilder也能够运用一个ForEach。相同的比如能够改写成这样:

struct ExampleView: View {
    let colors: [Color] = [.cyan, .blue, .indigo]
    var body: some View {
        Canvas { context, size in
            let r0 = context.resolveSymbol(id: 0)!
            let r1 = context.resolveSymbol(id: 1)!
            let r2 = context.resolveSymbol(id: 2)!
            context.draw(r0, at: .init(x: 10, y: 10), anchor: .topLeading)
            context.draw(r1, at: .init(x: 30, y: 20), anchor: .topLeading)
            context.draw(r2, at: .init(x: 50, y: 30), anchor: .topLeading)
            context.draw(r0, at: .init(x: 70, y: 40), anchor: .topLeading)
        } symbols: {
            ForEach(Array(colors.enumerated()), id: \.0) { n, c in
                RoundedRectangle(cornerRadius: 10.0).fill(c)
                    .frame(width: 100, height: 50)
                    .tag(n)
            }
        }
    }
}

符号的动画 – Animated Symbols

当我测验假如视图作为一个符号被解析为动画,会产生什么时,我感到十分惊喜。你猜怎么着,画布会不断地重绘它以保持动画作用。

SwiftUI 动画进阶 — Part 5:Canvas

struct ContentView: View {
    var body: some View {
        Canvas { context, size in
            let symbol = context.resolveSymbol(id: 1)!
            context.draw(symbol, at: CGPoint(x: size.width/2, y: size.height/2), anchor: .center)
        } symbols: {
            SpinningView()
                .tag(1)
        }
    }
}
struct SpinningView: View {
    @State private var flag = true
    var body: some View {
        Text("")
            .font(.custom("Arial", size: 72))
            .rotationEffect(.degrees(flag ? 0 : 360))
            .onAppear{
                withAnimation(.linear(duration: 1.0).repeatForever(autoreverses: false)) {
                    flag.toggle()
                }
            }
    }
}

改动图形上下文

图形上下文能够被改动,运用以下办法之一:

  • addFilter
  • clip
  • clipToLayer
  • concatenate
  • rotate
  • scaleBy
  • translateBy

假如你熟悉 AppKit 的 NSGraphicContext 或 CoreGraphic 的 CGContext,你或许习惯于从仓库中推送(保存)和弹出(康复)图形上下文状态。Canvas GraphicsContext 的工作办法有些不同,假如你想对上下文做一个暂时的改动,你有好几个挑选。

为了说明这一点,让咱们看看下面的比如。咱们需求用三种色彩画三座房子。只有中间的房子,需求被含糊化:

SwiftUI 动画进阶 — Part 5:Canvas

下面的一切比如将运用以下CGPoint扩展:

extension CGPoint {
    static func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
        return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
    }
    static func -(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
        return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y)
    }
}

这里有三种完成相同成果的办法:

1、经过对相应操作排序

在或许的状况下,你能够挑选以一种适合你的办法对制作操作进行排序。在这种状况下,最后制作含糊的房子,就能处理问题。否则,只需你增加了含糊过滤器,一切的制作操作都会持续含糊。

有时这或许是行不通的,即使能够,也或许变成难以阅览的代码。假如是这种状况,请检查其他选项。

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            // All drawing is done at x4 the size
            context.scaleBy(x: 4, y: 4)
            let midpoint = CGPoint(x: size.width / (2 * 4), y: size.height / (2 * 4))
            var house = context.resolve(Image(systemName: "house.fill"))
            // Left house
            house.shading = .color(.red)
            context.draw(house, at: midpoint - CGPoint(x: house.size.width, y: 0), anchor: .center)
            // Right house
            house.shading = .color(.blue)
            context.draw(house, at: midpoint + CGPoint(x: house.size.width, y: 0), anchor: .center)
            // Center house
            context.addFilter(.blur(radius: 1.0, options: .dithersResult), options: .linearColor)
            house.shading = .color(.green)
            context.draw(house, at: midpoint, anchor: .center)
        }
    }
}

2、经过复制上下文

因为图形上下文是一个值类型,你能够简略地创立一个副本。在副本上所做的一切改动,都不会影响到原始的上下文。一旦你完成了,你就能够持续在原始(未改动的)上下文上绘图。

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            // All drawing is done at x4 the size
            context.scaleBy(x: 4, y: 4)
            let midpoint = CGPoint(x: size.width / (2 * 4), y: size.height / (2 * 4))
            var house = context.resolve(Image(systemName: "house.fill"))
            // Left house
            house.shading = .color(.red)
            context.draw(house, at: midpoint - CGPoint(x: house.size.width, y: 0), anchor: .center)
            // Center house
            var blurContext = context
            blurContext.addFilter(.blur(radius: 1.0, options: .dithersResult), options: .linearColor)
            house.shading = .color(.green)
            blurContext.draw(house, at: midpoint, anchor: .center)
            // Right house
            house.shading = .color(.blue)
            context.draw(house, at: midpoint + CGPoint(x: house.size.width, y: 0), anchor: .center)
        }
    }
}	

3、经过运用图层上下文

最后,你能够运用 context 的办法: drawLayer。该办法有一个闭包,接纳一个你能够运用的上下文的副本。一切对图层上下文的改动都不会影响原始的上下文:

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            // All drawing is done at x4 the size
            context.scaleBy(x: 4, y: 4)
            let midpoint = CGPoint(x: size.width / (2 * 4), y: size.height / (2 * 4))
            var house = context.resolve(Image(systemName: "house.fill"))
            // Left house
            house.shading = .color(.red)
            context.draw(house, at: midpoint - CGPoint(x: house.size.width, y: 0), anchor: .center)
            // Center house
            context.drawLayer { layerContext in
                layerContext.addFilter(.blur(radius: 1.0, options: .dithersResult), options: .linearColor)
                house.shading = .color(.green)
                layerContext.draw(house, at: midpoint, anchor: .center)
            }
            // Right house
            house.shading = .color(.blue)
            context.draw(house, at: midpoint + CGPoint(x: house.size.width, y: 0), anchor: .center)
        }
    }
}

重用 CoreGraphics 代码

假如你现已有运用 CoreGraphics 的制作代码,你能够运用它。Canvas上下文有一个withCGContext办法,能够在如下这种状况下拯救你:

struct ExampleView: View {
    var body: some View {
        Canvas { context, size in
            context.withCGContext { cgContext in
                // CoreGraphics code here
            }
        }
    }
}

对画布进行动画处理

经过将Canvas包裹在TimelineView内,咱们能够完成一些相当风趣的动画。基本上,每一次时刻线的更新,你都有机会制作一个新的动画帧。

文章的其余部分假定你现已熟悉TimelineView,但假如你不熟悉,你能够查看本系列的第四部分来了解更多。

在下面的比如中,咱们的 Canvas 制作了一个给定日期的模仿时钟。经过将Canvas放在TimelineView内,并运用时刻线更新日期,咱们得到了动画时钟。以下屏幕截图的一部分是加速的,以显示分针和时针是怎么移动的,否则就不简略观察到作用:

SwiftUI 动画进阶 — Part 5:Canvas

当咱们用 Canvas 创立动画时,通常会运用时刻线时刻表的 .animation。这能够尽或许快地更新,每秒重绘咱们的 Canvas 几回。但是,在或许的状况下,咱们应该运用 minimumInterval 参数来约束每秒的更新次数。这样对CPU的要求会低一些。例如,在这种状况下,运用.animation.animation(minimumInterval: 0.06)在视觉上没有显着的差异。但是,在我的测验硬件上,CPU运用率从30%下降到14%。运用更高的最小间隔时刻或许开端变得视觉上显着,所以你或许需求做一些过错的试验,以找到最佳值。

SwiftUI 动画进阶 — Part 5:Canvas

为了进一步进步性能,你应该考虑Canvas中是否有一些部分不需求不断重绘。在咱们的比如中,只有时钟指针在移动,其他部分保持静止。因而,正确的做法是把它分成两个堆叠的画布。一个画除了钟针以外的一切东西(在时刻线视图之外),另一个只画钟针,在时刻线视图之内。经过施行这一改动,CPU从16%下降到6%。

struct Clock: View {
    var body: some View {
        ZStack {
            ClockFaceCanvas()
            TimelineView(.animation(minimumInterval: 0.06)) { timeline in
                ClockHandsCanvas(date: timeline.date)
            }
        }
    }
}

经过仔细剖析咱们的画布,并做了些许改动,咱们成功地将CPU的运用率进步到了5倍(从30%降到6%)。顺便说一下,假如你能承受每秒更新的秒针,你将进一步削减CPU的运用,使其低于1%。你应该经过测验来找到最适合你的作用。

分治

一旦咱们了解了Canvas,咱们或许会想用它来画一切。但是,有时最好的挑选是挑选做什么和在哪里做。下面这个Matrix Digital Rain动画便是一个很好的比如。

SwiftUI 动画进阶 — Part 5:Canvas

咱们来剖析一下其间的内容。咱们有一列字符呈现,字符数量增加,慢慢滑落,最后削减其字符,直到消失。每一列都是用突变制作的。还有一种深度感,经过使接近观察者的柱子滑动得更快和稍大。为了增加作用,柱子越靠后,它就越显得失焦(含糊)。

Canvas 中完成一切这些要求是完全或许的。但是,假如咱们把这些使命分割开来(分而治之),使命就会变得简略得多。正如咱们在本文的符号的动画部分现已看到的,一个带动画的SwiftUI视图能够经过一个draw()调用被制作到Canvas中。因而,并不是一切的东西都要在Canvas里面处理。

每一列都被完成为一个单独的SwiftUI视图。叠加字符和用突变绘图是由视图处理的。当咱们在画布上运用突变时,起始/完毕点或任何其他几许参数都是相对于整个画布的。对于柱状突变,在视图中完成它比较简略,因为它将相对于视图的原点。

每一列都有许多参数:方位(x、y、z)、字符、从顶部删去多少个字符,等等。这些值在每次TimelineView更新后都会被变更。

最后,Canvas负责解析每个视图,在它们的(x,y)方位上制作,并根据其z值增加含糊和缩放作用。我在代码中增加了一些注释,以帮助你阅览它,假如你有兴趣的话。

Canvas 溃散

不幸的是,在写这篇文章的时候,我遇到了 Canvas 的一些溃散问题。幸运的是,它们在每个测验版中都有很大的改善。我期望在iOS15正式发布时,它们都能得到处理。这条信息通常是这样的。

-[MTLDebugRenderCommandEncoder validateCommonDrawErrors:]:5252: failed assertion `Draw Errors Validation Fragment Function(primitive_gradient_fragment): argument small[0] from buffer(3) with offset(65460) and length(65536) has space for 76 bytes, but argument has a length(96).

我设法处理了这些溃散的问题,至少运用了其间一个办法:

  • 削减绘图量。在数字雨的比如中,你能够削减列的数量。
  • 运用更简略的突变。最初,数字雨柱有三个色彩的突变。当我把它削减到两个时,溃散就消失了。
  • 削减更新Canvas的频率。运用较慢的时刻轴视图,能够防止溃散。

我并不是说你不能运用超越两种色彩的突变,但这只是你能够考虑的一个当地,假如你发现自己处于Canvas溃散的状况。假如这还不能处理你的问题,我建议你开端删去绘图操作,直到运用程序不再溃散。这能够引导你找到导致溃散的原因。一旦你知道是什么原因,你能够测验用不同的办法来做。

假如你遇到这个问题,我鼓励你向苹果公司反应。假如你愿意,你能够参阅我的反应:FB9363322。

总结

我期望这篇文章能帮助你为你的SwiftUI动画工具箱增加一个新的工具。第五部分的动画系列到此完毕。至少在本年……谁知道WWDC 22会带来什么呢!