前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >SwiftUI 动画进阶 — Part 5:Canvas

SwiftUI 动画进阶 — Part 5:Canvas

作者头像
Swift社区
发布2022-04-04 15:45:10
2.7K0
发布2022-04-04 15:45:10
举报
文章被收录于专栏:Swift社区

前言

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

我不得不把这篇文章推迟几周,因为 Canvas 视图有点不稳定。我们仍然处于测试阶段,所以这是可以预期的。然而,该视图产生的崩溃使这里的一些例子无法分享。虽然不是所有的问题都得到了解决,但现在每个例子都能顺利运行。在文章的最后,我将指出我找到的一些解决方法。

一个简单的 Canvas

简而言之,画布Canvas 是一个 SwiftUI 视图,它从一个渲染闭包中获得绘制指令。与 SwiftUI API 中的大多数闭包不同,它不是一个视图生成器。这意味着我们可以使用 Swift 语言且没有任何限制。

该闭包接收两个参数:上下文context 和 尺寸size。上下文使用一个新的 SwiftUI 类型 GraphicsContext,它包含了很多方法和属性,可以让我们绘制任何东西。下面是一个关于如何使用 Canvas 的基本例子。

代码语言:javascript
复制
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 的第一个版本开始,路径可以通过多种方式创建和修改。一些可用的初始化器是:

代码语言:javascript
复制
let path = Path(roundedRect: rect, cornerSize: CGSize(width: 10, height: 50), style: .continuous)
代码语言:javascript
复制
let cgPath = CGPath(ellipseIn: rect, transform: nil)
let path = Path(cgPath)
代码语言:javascript
复制
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:

代码语言:javascript
复制
let path = Circle().path(in: rect)

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

代码语言:javascript
复制
let path = MyCustomShape().path(in: rect)

填充路径

要填充一个路径,请使用 context.fill() 方法:

代码语言:javascript
复制
fill(_ path: Path, with shading: GraphicsContext.Shading, style: FillStyle = FillStyle())

着色shading表示如何填充形状(用颜色、渐变、平铺图像等)。如果你需要指示要使用的样式,请使用FillStyle类型(即偶数奇数/反义属性)。

路径描边 - Stroke

要描画一个路径,请使用这些GraphicsContext方法中的一个:

代码语言:javascript
复制
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()方法绘制的,有两个版本:

代码语言:javascript
复制
draw(image_or_text, at point: CGPoint, anchor: UnitPoint = .center)
draw(image_or_text, in rect: CGRect)

在图像的情况下,第二个draw()版本有一个额外的可选参数,style:

代码语言:javascript
复制
draw(image, in rect: CGRect, style: FillStyle = FillStyle())

在这些元素之一可以被绘制之前,它们必须被解析。通过解析,SwiftUI将考虑到环境(例如,颜色方案、显示分辨率等)。此外,解析这些元素会暴露出一些有趣的属性,这些属性可能会被进一步用于我们的绘制逻辑。例如,解析后的文本会告诉我们指定字体的最终尺寸。或者我们也可以在绘制之前改变已解析元素的阴影。要了解更多关于可用的属性和方法,请查看 ResolvedImage 和 ResolvedText 。

使用上下文的resolve()方法从Image中获得ResolvedImage,从Text中获得ResolvedText

解析是可选的,draw()方法也接受ImageText(而不是ResolvedImageResolvedText)。在这种情况下,draw()会自动解析它们。如果你对已解析的属性和方法没有任何用途,这很方便。

在这个例子中,文本被解决了。我们用它的大小来计算渐变,并用着色shading来应用这种渐变:

代码语言:javascript
复制
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上绘制不止一次。

代码语言:javascript
复制
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。同样的例子可以改写成这样:

代码语言:javascript
复制
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

当我测试如果视图作为一个符号被解析为动画,会发生什么时,我感到非常惊喜。你猜怎么着,画布会不断地重绘它以保持动画效果。

代码语言:javascript
复制
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 的工作方式有些不同,如果你想对上下文做一个临时的改变,你有好几个选择。

为了说明这一点,让我们看看下面的例子。我们需要用三种颜色画三座房子。只有中间的房子,需要被模糊化:

下面的所有例子将使用以下CGPoint扩展:

代码语言:javascript
复制
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、通过对相应操作排序

在可能的情况下,你可以选择以一种适合你的方式对绘制操作进行排序。在这种情况下,最后绘制模糊的房子,就能解决问题。否则,只要你添加了模糊过滤器,所有的绘制操作都会继续模糊。

有时这可能是行不通的,即使可以,也可能变成难以阅读的代码。如果是这种情况,请检查其他选项。

代码语言:javascript
复制
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、通过拷贝上下文

由于图形上下文是一个值类型,你可以简单地创建一个副本。在副本上所做的所有改变,都不会影响到原始的上下文。一旦你完成了,你就可以继续在原始(未改变的)上下文上绘图。

代码语言:javascript
复制
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。该方法有一个闭包,接收一个你可以使用的上下文的副本。所有对图层上下文的改变都不会影响原始的上下文:

代码语言:javascript
复制
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方法,可以在如下这种情况下拯救你:

代码语言:javascript
复制
struct ExampleView: View {
    
    var body: some View {
        Canvas { context, size in
            
            context.withCGContext { cgContext in
                
                // CoreGraphics code here
                
            }
        }
    }
}

对画布进行动画处理

通过将Canvas包裹在TimelineView内,我们可以实现一些相当有趣的动画。基本上,每一次时间线的更新,你都有机会绘制一个新的动画帧。

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

在下面的例子中,我们的 Canvas 绘制了一个给定日期的模拟时钟。通过将Canvas放在TimelineView内,并使用时间线更新日期,我们得到了动画时钟。以下屏幕截图的一部分是加速的,以显示分针和时针是如何移动的,否则就不容易观察到效果:

当我们用 Canvas 创建动画时,通常会使用时间线时间表的 .animation。这可以尽可能快地更新,每秒重绘我们的 Canvas 几次。然而,在可能的情况下,我们应该使用 minimumInterval 参数来限制每秒的更新次数。这样对CPU的要求会低一些。例如,在这种情况下,使用.animation.animation(minimumInterval: 0.06)在视觉上没有明显的区别。然而,在我的测试硬件上,CPU使用率从30%下降到14%。使用更高的最小间隔时间可能开始变得视觉上明显,所以你可能需要做一些错误的试验,以找到最佳值。

为了进一步提高性能,你应该考虑Canvas中是否有一些部分不需要不断重绘。在我们的例子中,只有时钟指针在移动,其他部分保持静止。因此,明智的做法是把它分成两个重叠的画布。一个画除了钟针以外的所有东西(在时间线视图之外),另一个只画钟针,在时间线视图之内。通过实施这一改变,CPU从16%下降到6%。

代码语言:javascript
复制
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动画就是一个很好的例子。

数字雨的完整代码可以在这里找到。

我们来分析一下其中的内容。我们有一列字符出现,字符数量增长,慢慢滑落,最后减少其字符,直到消失。每一列都是用渐变绘制的。还有一种深度感,通过使靠近观察者的柱子滑动得更快和稍大。为了增加效果,柱子越靠后,它就越显得失焦(模糊)。

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崩溃的情况。如果这还不能解决你的问题,我建议你开始删除绘图操作,直到应用程序不再崩溃。这可以引导你找到导致崩溃的原因。一旦你知道是什么原因,你可以尝试用不同的方法来做。

如果你遇到这个问题,我鼓励你向苹果公司反馈。

总结

我希望这篇文章能帮助你为你的SwiftUI动画工具箱添加一个新的工具。第五部分的动画系列到此结束。至少在今年......谁知道WWDC'22会带来什么呢!

译自 Advanced SwiftUI Animations – Part 5: Canvas

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-03-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Swift社区 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 一个简单的 Canvas
  • 图形上下文 - GraphicsContext
  • 路径 - Paths
    • 填充路径
      • 路径描边 - Stroke
        • 图片和文本 - Image & Text
          • 符号 - Symbols
            • 符号的动画 - Animated Symbols
              • 2、通过拷贝上下文
              • 3、通过使用图层上下文
          • 改变图形上下文
          • 重用 CoreGraphics 代码
            • 对画布进行动画处理
              • 分治
          • Canvas 崩溃
          • 总结
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档