SwiftUI基础篇Drawing

2,476 阅读7分钟

Drawing

概述

文章主要分享SwiftUI Modifier的学习过程,将使用案例的方式进行说明。内容浅显易懂,Drawing展示部分调试结果,不过测试代码是齐全的。如果想要运行结果,可以移步Github下载code -> github案例链接

1、SwiftUI的内置形状

SwiftUI提供了五种常用内置形状:矩形,圆角矩形,圆形,椭圆形和胶囊形状。特别是后三个,他们的行为会根据大小而有微妙的不同。

struct FFShapesBuiltin: View {
    var body: some View {
        VStack {
            //长方形
            Rectangle()
                .fill(.gray)
                .frame(width: 200, height: 200)
            //4个圆角长方形
            RoundedRectangle(cornerRadius: 25.0)
                .fill(.red)
                .frame(width: 200, height: 200)
            //可独立单独设置圆角的长方形。
            UnevenRoundedRectangle(cornerRadii: .init(topLeading: 50, topTrailing: 50))
                .fill(.orange)
                .frame(width: 200, height: 200)
            //胶囊
            Capsule()
                .fill(.green)
                .frame(width: 100, height: 50)
            //椭圆形
            Ellipse()
                .fill(.blue)
                .frame(width: 100, height: 50)
            //圆形
            Circle()
                .fill(.black)
                .frame(width: 100, height: 50)
        }
    }
}

一共绘制了五个图形,两个200*200,三个100*50,由于各种形状的特殊性,即使使用ZStack容器,也是可以全部显示的,我这里使用的是VStack,更直观。

  • Rectangle绘制一个基本样式的矩形。
  • RoundedRectangle同样绘制的也是矩形,只不过可以将拐角按照一定的数值设置为圆角。
  • UnevenRoundedRectangle是一个圆角矩形,可以针对单个角设定为圆角。对于任何的角,默认值为0,即直角。但是可以更改值。
  • Capsule绘制一个盒子,UI更像一个胶囊,其中较短的边会完全圆化,比如定义一个100*50的图形,那么短边50就是编程圆边。
  • Ellipse绘制一个基本样式的椭圆。
  • Circle绘制一个高度和宽度相等的椭圆,即圆形。

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 15.45.19.png

2、绘制自定义路径

SwiftUI可以根据Shape协议绘制自定义路径,这样就可以创建自定义形状,可以是矩形、胶囊图形、圆形等。遵守这个协议并不难,因为需要做的就是支持一个接受CGRect并返回path的path(in:)方法。可以使用之前用CGPathUIBezierPath构建的任何路径,然后将结果转换为SwiftUI路径。

如果想使用SwiftUI的原生路径类型,创建他的一个实例变量,然后根据需要添加尽可能多的点或形状。不需要考虑颜色、填充或边框宽度。这里只关注原始类型,这些设置是在使用自定义path时设定的。

struct ShrinkingSquares: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        for i in stride(from: 1, to: 100, by: 5.0) {
            let rect = CGRect(x: 0, y: 0, width: rect.width, height: rect.height)
            let insetRect = rect.insetBy(dx: i, dy: i)
            path.addRect(insetRect)
        }
        return path
    }
}

struct FFDramCustomPath: View {
    var body: some View {
        ShrinkingSquares()
            .stroke()
            .frame(width: 200, height: 200)
    }
}

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 15.49.27.png

3、绘制多边形和星星

如果了解了SwiftUI的基本路径绘制原理,就可以轻松的添加各种形状了。例如,创建一个星星,可以表示各种各样的图形,甚至多边形,通过一些数学模型。

构建星星

struct Star: Shape {
    //存储星星有多少个角,以及它的平滑度
    let corners: Int
    let smoothness: Double
    
    func path(in rect: CGRect) -> Path {
        //首先要保证绘制的路径上至少有两个角,否则路径无效
        guard corners >= 2 else { return Path() }
        //确定要绘制图形的中心点(假设绘制的是矩形,就获取矩形中心)
        let center = CGPoint(x: rect.width / 2, y: rect.height / 2)
        //绘制方向是上方开始
        var currentAngle = -CGFloat.pi / 2
        //计算每个星星图形角需要移动多少距离
        let angleAdjustment = .pi * 2 / Double(corners * 2)
        //计算星星图形内部点需要移动多少X/Y
        let innerX = center.x * smoothness
        let innerY = center.y * smoothness
        //创建路径
        var path = Path()
        //移动到初始位置
        path.move(to: CGPoint(x: center.x * cos(currentAngle), y: center.y * sin(currentAngle)))
        //创建一个绘制最低点,用来图形的居中计算
        var bottomEdge: Double = 0
        //循环遍历所有的点
        for corner in 0..<corners * 2 {
            //计算该点的位置
            let sinAngle = sin(currentAngle)
            let cosAngle = cos(currentAngle)
            let bottom: Double
            //当是2的倍数,绘制的就是星星的外边缘
            if corner.isMultiple(of: 2) {
                //存储Y点位置
                bottom = center.y * sinAngle
                //绘制点与点之间的线段
                path.addLine(to: CGPoint(x: center.x * cosAngle, y: bottom))
            } else {
                //如果不是2的倍数,那么就绘制内边
                bottom = innerY * sinAngle
                path.addLine(to: CGPoint(x: innerX * cosAngle, y: bottom))
            }
            //判断当前bottom是否是最地点的值,如果不是就更新
            if bottom > bottomEdge {
                bottomEdge = bottom
            }
            //移动到下一个点
            currentAngle += angleAdjustment
        }
        //计算画布(外面传递的frame)底部还有多少未使用的空间
        let unusedSpace = (rect.height / 2 - bottomEdge) / 2
        //创建transform,将路径向下移动,使图形垂直居中
        let transform = CGAffineTransform(translationX: center.x, y: center.y + unusedSpace)
        return path.applying(transform)
    }
}

3.1、绘制五角星

struct FFDrawPolygons: View {
    var body: some View {
        Star(corners: 5, smoothness: 0.45)
            .fill(.red)
            .frame(width: 200, height: 200)
            .background(.green)
    }
}

3.2、绘制多边形

struct FFDrawPolygons: View {
    var body: some View {
        Star(corners: 5, smoothness: 1)
            .fill(.red)
            .frame(width: 200, height: 200)
            .background(.green)
    }
}

由于星星是多边形,只需要将平滑度调整到1,就可以绘制多边形,代码没变化。

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 15.55.06.png

4、绘制一个棋盘

SwiftUI的路径不需要时连续的、孤立的形状,而是可以为多个矩形、椭圆或更多的形状,所有这些都可以组成一个。

构建棋盘

struct CheckerBorad: Shape {
    let rows: Int
    let columns: Int
    
    func path(in rect: CGRect) -> Path {
        var path = Path()
        //计算出行和列的空间大小
        let rowSize = rect.height / Double(rows)
        let columnsSize = rect.height / Double(columns)
        //循环遍历所有行和列,使方块交替着色。
        for row in 0 ..< rows {
            for column in 0 ..< columns {
                if (row + column).isMultiple(of: 2) {
                    //满足条件绘制方块
                    let startX = columnsSize * Double(column)
                    let startY = columnsSize * Double(row)
                    
                    let rect = CGRect(x: startX, y: startY, width: columnsSize, height: rowSize)
                    path.addRect(rect)
                }
            }
        }
        return path
    }
}

由于集成链关系间接继承View,即与其他View视图使用方式一致

struct FFDrawCheckerboard: View {
    var body: some View {
        CheckerBorad(rows: 10, columns: 10)
            .fill(.gray)
            .frame(width: 300, height: 300)
    }
}

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 15.59.21.png

5、在SwiftUI中使用UIBezierPath和CGPath

如果你有使用UIBeizerPathCGPath的现有路径,转换他们在SwiftUI中使用很简单,因为Path结构体有一个直接来自CGPath的初始化器。

注意:UIBezierPath在macOS中不可用,如果想让SwiftUI代码跨平台,应该迁移到CGPath。

绘制贝塞尔

extension UIBezierPath {
    //Unwrap标识为贝塞尔路径
    static var logo: UIBezierPath {
        let path = UIBezierPath()
        path.move(to: CGPoint(x: 0.534, y: 0.5816))
        path.addCurve(to: CGPoint(x: 0.1877, y: 0.088), controlPoint1: CGPoint(x: 0.534, y: 0.5816), controlPoint2: CGPoint(x: 0.2529, y: 0.4205))
        path.addCurve(to: CGPoint(x: 0.9728, y: 0.8259), controlPoint1: CGPoint(x: 0.4922, y: 0.4949), controlPoint2: CGPoint(x: 1.0968, y: 0.4148))
        path.addCurve(to: CGPoint(x: 0.0397, y: 0.5431), controlPoint1: CGPoint(x: 0.7118, y: 0.5248), controlPoint2: CGPoint(x: 0.3329, y: 0.7442))
        path.addCurve(to: CGPoint(x: 0.6211, y: 0.0279), controlPoint1: CGPoint(x: 0.508, y: 1.1956), controlPoint2: CGPoint(x: 1.3042, y: 0.5345))
        path.addCurve(to: CGPoint(x: 0.6904, y: 0.3615), controlPoint1: CGPoint(x: 0.7282, y: 0.2481), controlPoint2: CGPoint(x: 0.6904, y: 0.3615))
        return path
    }
}

构建一个遵守Shape协议的贝塞尔View

使用的控制点被归化为0-1的范围,这样就可以在任何类型的容器中渲染它并将其缩放以适应可用空间。在SwiftUI中,这意味着创建一个transform,将贝塞尔路径锁放到最小的宽度或高度,然后将其应用到路径上。


struct ScaledBezier: Shape {
    let bezierPath: UIBezierPath
    
    func path(in rect: CGRect) -> Path {
        let path = Path(bezierPath.cgPath)
        //计算出我们需要多大的路径来填充可用空间而不进行剪切
        let multiplier = min(rect.width, rect.height)
        //创建一个仿射transform,对两个维度使用相同的乘数
        let transform = CGAffineTransform(scaleX: multiplier, y: multiplier)
        //应用该比例返回结果
        return path.applying(transform)
    }
}

使用自定义贝塞尔图形绘制

struct FFUIBezierPathAndCGPath: View {
    var body: some View {
        ScaledBezier(bezierPath: .logo)
            .stroke(lineWidth: 2)
            .frame(width: 200, height: 200)
    }
}

如果使用CGPath而不是UIBezierPath,操作就更加的简单,可以直接使用 let path = path(...)来直接绘制路径。

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 16.11.06.png

6、将SwiftUI视图转换为图像

wiftUI的ImageRenderer类能将任何SwiftUI视图渲染成图像,然后可以保存、共享或以其他方式重用。

6.1、基础样式

用这种方式进行图像的渲染,有四个关键注意点:

  1. 如果你没有指定,你的图形将以1倍的比例渲染,在2倍和3倍的分辨率的屏幕上看起来模糊
  2. 不能试图在主role之外使用ImageRenderer,这可能意味着用@MainActor标记你的渲染代码。
  3. 可以把想要渲染的SwiftUI视图放到ImageRenderer(conteng:)初始化器中,但发现把他们分离到一个专门的视图中会产生更简单的代码。
  4. 不像旧的UIGraphicsImageRenderer,没有简单的方法直接从ImageRenderer读取PNG或JPEG数据,所以可以在代码中看到,需要读取UIImage的结果,然后调用它的pngData()方法。这样的代码对于跨平台用户来说更复杂。
struct RenderView: View {
    let text: String
    var body: some View {
        Text(text)
            .font(.largeTitle)
            .foregroundStyle(.white)
            .padding()
            .background(.blue)
            .clipShape(Capsule())
    }
}

struct FFConvertToImage: View {
    @State private var text = "Your text here"
    @State private var renderedImage = Image(systemName: "photo")
    
    var body: some View {
        
        VStack {
            renderedImage
            
            ShareLink("Export", item: renderedImage, preview: SharePreview(Text("Shared image"), image: renderedImage))
            TextField("Enter some text", text: $text)
                .textFieldStyle(.roundedBorder)
                .padding()
        }
        .onChange(of: text) { oldValue, newValue in
            render()
        }
        //正如所看到的,它在显示视图时调用render(),也在文本改变时调用render().
    }
    
    @MainActor func render() {
        let renderer = ImageRenderer(content: RenderView(text: text))
        if let uiImage = renderer.uiImage {
            renderedImage = Image(uiImage: uiImage)
        }
    }
}

6.2、将视图转换为图像保存在相册中

如果版本在iOS15以下,那么SwiftUI的视图没有内置功能将视图渲染成图像,只能自己创造一个。关键点为使用UIHostingContontroller来包装视图,然后将其视图层次结构渲染到UIGraphicsImageRenderer中。

这最好使用View上的扩展来完成,这样就可以正常调用了。应该将视图封装在托管控制器中,调整托管控制器视图的大小,使其成为SwitUI视图的内在内容大小,清除任何背景色以保持渲染图像的干净,然后将视图渲染成图像并返回。

extension View {
    func snapshot() -> UIImage {
        let controller = UIHostingController(rootView: self)
        let view = controller.view
        
        let targetSize = controller.view.intrinsicContentSize
        view?.bounds = CGRect(origin: .zero, size: targetSize)
        view?.backgroundColor = .clear
        
        let renderer = UIGraphicsImageRenderer(size: targetSize)
        
        return renderer.image { _ in
            view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
        }
    }
}

要在SwiftUI中使用这个场景,应该把视图创建作为一个属性,这样就可以在需要的时候引用它,比如,在按钮被点击时。例如,将渲染一个文本视图编程一个图像,然后将其保存在用户的相册中。

struct FFConvertToImage: View {
    @Environment(\.displayScale) var displayScale
    var textView: some View {
        Text("Hello, metaBBLv")
            .padding()
            .foregroundStyle(.white)
            .background(.blue)
            .clipShape(Capsule())
    }
    
    var body: some View {
        VStack {
            textView
            
            Button("Save to image") {
                let image = textView.snapshot()
                UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
            }
        }
    }
}

调试结果

  • 前两张图,当输入时可以动态更改视图Text,然后可以导出为Image到其他的应用
  • 第三张图是第二个例子,将视图转换为Image保存在相册中,目前还有些问题(横屏可以,竖屏GG)。有兴趣的小伙伴可以自己获取demo玩一下。
Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 16.31.42.png Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 16.32.14.png Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 16.33.03.png

7、将SwiftUI视图渲染成PDF

SwiftUI的ImageRenderer类可以将任何SwiftUI视图渲染为PDF。使用ImageRenderer创建PDF需要八个步骤:

  1. 决定要渲染那些视图
  2. 创建一个SwiftUI可以写入图像数据的URL
  3. 在图像渲染器上调用render()来启动渲染代码。
  4. 告诉SwiftUI你想要多大的PDF。可能是一个固定的大小,例如A4或US Letter,也可能是你正在呈现的视图大小。
  5. 创建一个CGContext对象来处理PDF页面
  6. 创建新的page
  7. 将SwiftUI视图呈现到该页面上
  8. 结束页面,并关闭PDF文档。
@MainActor
struct FFViewToPDF: View {
    var body: some View {
        Text("Hello, metaBBLv")
            .font(.largeTitle)
            .foregroundStyle(.white)
            .padding()
            .background(.blue)
            .clipShape(Capsule())
        ShareLink("Export PDF", item: render())
        
    }
    
    func render() -> URL {
        //1. 渲染文本
        let renderer = ImageRenderer(content:
                                        Text("Hello, metaBBLv")
            .font(.largeTitle)
            .foregroundStyle(.white)
            .padding()
            .background(.blue)
            .clipShape(Capsule())
        )
        //2. 保存到文档目录
        let url = URL.documentsDirectory.appending(path: "output.pdf")
        //3. 启动渲染进程
        renderer.render { size, context in
            //4. 告诉SwiftUI,我们的PDF应该和我们渲染的视图一样大
            var box = CGRect(x: 0, y: 0, width: size.width, height: size.height)
            //5. 为PDF页面创建CGContext
            guard let pdf = CGContext(url as CFURL, mediaBox: &box, nil) else {
                return
            }
            //6. 创建一个新的PDF页面
            pdf.beginPDFPage(nil)
            //7. 将SwiftUI视图数据渲染到页面
            context(pdf)
            //8. 结束操作并关闭文件
            pdf.endPDFPage()
            pdf.closePDF()
        }
        return url
    }
}

@MainActor
#Preview {
    FFViewToPDF()
}

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 16.42.44.png Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 16.42.49.png Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 16.43.30.png

8、添加视觉效果模糊

SwiftUI有一个非常简单的特效UIVisualEffectView,它结合了ZStack和background修饰符的一些特性。 可以调整“厚度”,材质从最薄到最厚依次是:

  • .ultraThimMaterial
  • .thinNaterial
  • .regularMaterial
  • .thickMaterial
  • .ultraThickMaterial
struct FFVisualEffectBlurs: View {
    var body: some View {
        //将一些文本放在图像上,对文本应用标准模糊效果,
        ZStack {
            Image(.chrysanthemumTea)
            Text("Hi, metaBBLv")
                .padding()
                .background(.thinMaterial)
        }
        
        //如果使用的是次要前景样式,SwiftUI会自动调整文本颜色,使其效果更突出
        ZStack {
            Image(.chrysanthemumTea)
            Text("Hi, metaBBLv")
                .foregroundStyle(.secondary)
                .padding()
                .background(.ultraThinMaterial)
        }
    }
}

调试结果

Simulator Screenshot - iPhone 14 Pro - 2023-08-24 at 16.47.41.png

9、在SwiftUI视图上使用layerEffects添加Metal(Shaders)

SwiftUI提供了与Metal着色器的广泛集成,就在视图级别,可以以卓越的性能操纵颜色、形状等等。步骤分为三步:

  1. 用你的作色器创建一个Metal文件。这必须有一个确切的函数签名,取决于你想要应用的效果。
  2. 创建你的SwiftUI视图,并附加一个或多个效果。
  3. 可选的为视图添加视觉效果,以便在不改变布局的情况下读取视图的大小。

Metal文件

这里面的每一个着色器效果都在下面SwiftUI文件中应用了,由于我偷懒了,并没有把每个都分离开做成一组一组的,哈哈哈哈。

//
//  FFMetal.metal
//  FFModifier
//
//  Created by BBLv on 2023/8/23.
//
//  要构建需要从视图冲采样颜色的着色器,将Metal文件导入头文件#include <SwiftUI/SwiftUI_Metal.h>,然后确保你的作色器签名接受位置和实例(SwiftUI::Layer)
#include <metal_stdlib>
#include <SwiftUI/SwiftUI_Metal.h>

using namespace metal;

//在该着色器中,SwiftUI需要前两个参数,它将自动传递视图的位置即当前颜色,第二个和其余参数都是我创建的,需要手动发送,这个着色器,我传递了类似棋盘的方块
[[ stitchable ]] half4 checkerboard(float2 position, half4 currentColor, float size, half4 newColor) {
    uint2 posInChecks = uint2(position.x / size, position.y / size);
    bool isColor = (posInChecks.x ^ posInChecks.y) & 1;
    return isColor ? newColor * currentColor.a : half4(0.0, 0.0, 0.0, 0.0);
}

//首先,你可以通过着色器放置在TimeLineView内并发送日期值来制作动画着色器。例如,可以创建一个开始日期并发送该开始日期和当前日期之间的差异来为shader提供动力
[[ stitchable ]] half4 noise(float2 position, half4 currentColor, float time) {
    float value = fract(sin(dot(position + time, float2(12.9898, 78.233))) * 43758.5453);
    return half4(value, value, value, 1) * currentColor.a;
}

//像素化的着色器:将函数的输入限制为下限0.0001,以避免除以0,然后将每个像素的位置除以强度,四舍五入,然后再次相乘,导致像素数据被丢弃。真正的工作是调用layer.sample(),它从附加了该着色器的视图中读取一种颜色。
[[ stitchable ]] half4 pixellate(float2 position, SwiftUI::Layer layer, float strength) {
    float min_strength = max(strength, 0.0001);
    float coord_x = min_strength * round(position.x / min_strength);
    float coord_y = min_strength * round(position.y / min_strength);
    return layer.sample(float2(coord_x, coord_y));
}

//可以将一个像素从一个位置移动到赢一个位置,而保持其他位置不变。着意味着着色器只需要接受最小值的像素位置,因此可以创建波形实例
[[ stitchable ]] float2 simpleWave(float2 position, float time) {
    return position + float2 (sin(time + position.y / 20), sin(time + position.x / 20)) * 5;
}

//如果想要一个更像水下视图的复杂波浪着色器,那么就需要读取图像的整体大小。这需要更多的思考,因为需要将扭曲效果包装在视觉效果中以提供视图的size。首先,这是一个更复杂的波浪效果,需要视图的大小,但也有速度、强度和波浪频率的选项,以便更加可定制,
[[ stitchable ]] float2 complexWave(float2 position, float time, float2 size, float speed, float strength, float frequency) {
    float2 normalizedPosition = position / size;
    float moveAmount = time * speed;
    
    position.x += sin((normalizedPosition.x + moveAmount) * frequency) * strength;
    position.y += cos((normalizedPosition.y + moveAmount) * frequency) * strength;
    
    return  position;
}

//浮雕过滤器着色器
[[ stitchable ]] half4 emboss(float2 position, SwiftUI::Layer layer, float strength) {
    half4 current_color = layer.sample(position);
    half4 new_color = current_color;

    new_color += layer.sample(position + 1) * strength;
    new_color -= layer.sample(position - 1) * strength;

    return half4(new_color);
}

实际应用效果

此处无需多言,享受视觉效果,不得不说,Metal是真的牛boi

struct FFMetalShaders: View {
    let startDate = Date()
    let startDate1 = Date()
    let startDate2 = Date()
    @State private var strength = 3.0
    
    var body: some View {
        ScrollView {
            Image(systemName: "figure.run.circle.fill")
                .font(.system(size: 300))
                .colorEffect(ShaderLibrary.checkerboard(.float(10), .color(.blue)))
            
            TimelineView(.animation) { context in
                Image(systemName: "figure.run.circle.fill")
                    .font(.system(size: 300))
                    .colorEffect(ShaderLibrary.noise(.float(startDate.timeIntervalSinceNow)))
            }
            //着色器需要作为涂层效果来调用,它告诉SwiftUI传入整个涂层以及我们正在处理的当前像素的位置。
            Image(systemName: "figure.run.circle.fill")
                .font(.system(size: 300))
                .layerEffect(ShaderLibrary.pixellate(.float(10)), maxSampleOffset: .zero)
            //另外一种效果是使用distortionEffect()修改器激活的
            TimelineView(.animation) { context in
                Image(systemName: "figure.run.circle.fill")
                    .font(.system(size: 300))
                    .distortionEffect(ShaderLibrary.simpleWave(.float(startDate1.timeIntervalSinceNow)), maxSampleOffset: .zero)
            }
            //要使用它,同时需要使用visualEffect()和distortionEffect()
            TimelineView(.animation) { context in
                Image(systemName: "figure.run.circle.fill")
                    .font(.system(size: 300))
                    .visualEffect { content, proxy in
                        content
                            .distortionEffect(ShaderLibrary.complexWave(
                                .float(startDate2.timeIntervalSinceNow),
                                .float2(proxy.size),
                                .float(0.5),
                                .float(8),
                                .float(10)
                            ), maxSampleOffset: .zero)
                    }
            }
            //创建一个简单的浮雕过滤器,包括一个slider控制器,用户控制浮雕强度的SwiftUI
            Image(systemName: "figure.run.circle.fill")
                .foregroundStyle(.linearGradient(colors: [.orange, .red], startPoint: .top, endPoint: .bottom))
                .font(.system(size: 300))
                .layerEffect(ShaderLibrary.emboss(.float(strength)), maxSampleOffset: .zero)
            Slider(value: $strength, in: 0...20)
            //结果可得,将Metal着色器添加到SwiftUI视图中非常简单,无需大量代码即可解锁非常复杂的特效。
        }
    }
}

调试结果

iShot2023-08-24 17.06.21.gif