SwiftUI - 在视图之间绘制(弯曲的)路径

SwiftUI - Drawing (curved) paths between views

用非常基本的术语来说,在我的 Android 应用程序中,我有一个屏幕可以绘制圆圈,然后用曲线将它们连接起来。

我正在尝试在 SwiftUI 中重新创建它。

我发现这个问题似乎与我正在寻找的问题非常相似,但不幸的是答案非常简短,甚至在阅读了大约 10 个不同的博客和 5 个视频之后,我仍然没有完全理解它。


所以基本逻辑是我以某种方式使用 GeometryReader 获取我创建的每个 Circle.midX.midY 坐标,然后绘制 Paths它们之间。 我唯一的问题是在创建圆后获取这些坐标。

以及如何将路径添加到屏幕,在 ZStack 中,Circles 在前面,路径作为一个自定义形状在后面?


更多信息:
在 Android 上,最终结果如下所示:

基本上我有一个 Challenge object,它有一个名字和一些详细的文字,我把它们排成这样,这样它在视觉上代表了用户的“旅程”。

所以我真正需要的是知道如何布置一些 circles/images(上面有文字)然后画线连接它们。每个这样的挑战圈都需要可点击才能打开详细视图。

GeometryReader 给你信息,如果容器视图,你可以得到像 geometry.size 这样的大小,然后计算中点等

内部 GeometryReader 布局是 ZStack,因此所有项目都将一个放在另一个之上

绘制曲线的简单方法是 Path { path in },在此块中,您可以将 lines/curves 添加到路径,而不是 stoke()

您可以通过两种方式绘制圆:第一种是再次使用 Path,添加圆角矩形并 fill() 它。

另一种选择是放置 Circle() 并添加一个偏移量

我用第一种方式用蓝色,用第二种方式用绿色,半径较小。我随机选择了曲线控制点只是为了给你一个想法

let circleRelativeCenters = [
    CGPoint(x: 0.8, y: 0.2),
    CGPoint(x: 0.2, y: 0.5),
    CGPoint(x: 0.8, y: 0.8),
]

var body: some View {
    GeometryReader { geometry in
        let normalizedCenters = circleRelativeCenters
            .map { center in
                CGPoint(
                    x: center.x * geometry.size.width,
                    y: center.y * geometry.size.height
                )
            }
        Path { path in
            var prevPoint = CGPoint(x: normalizedCenters[0].x / 4, y: normalizedCenters[0].y / 2)
            path.move(to: prevPoint)
            normalizedCenters.forEach { center in
                    path.addQuadCurve(
                        to: center,
                        control: .init(
                            x: (center.x + prevPoint.x) / 2,
                            y: (center.y - prevPoint.y) / 2)
                    )
                    prevPoint = center
            }
        }.stroke(lineWidth: 3).foregroundColor(.blue).background(Color.yellow)
        Path { path in
            let circleDiamter = geometry.size.width / 5
            let circleFrameSize = CGSize(width: circleDiamter, height: circleDiamter)
            let circleCornerSize = CGSize(width: circleDiamter / 2, height: circleDiamter / 2)
            normalizedCenters.forEach { center in
                path.addRoundedRect(
                    in: CGRect(
                        origin: CGPoint(
                            x: center.x - circleFrameSize.width / 2,
                            y: center.y - circleFrameSize.width / 2
                        ), size: circleFrameSize
                    ),
                    cornerSize: circleCornerSize
                )
            }
        }.fill()
        ForEach(normalizedCenters.indices, id: \.self) { i in
            let center = normalizedCenters[i]
            let circleDiamter = geometry.size.width / 6
            let circleFrameSize = CGSize(width: circleDiamter, height: circleDiamter)
            Circle()
                .frame(size: circleFrameSize)
                .offset(
                    x: center.x - circleFrameSize.width / 2,
                    y: center.y - circleFrameSize.width / 2
                )
        }.foregroundColor(.green)
    }.frame(maxWidth: .infinity, maxHeight: .infinity).foregroundColor(.blue).background(Color.yellow)
}

结果:


Path { path in 内部我可以使用 forEach,因为它不再是视图构建器的范围。

如果您需要对修饰符进行一些计算,可以使用下一个技巧:

func circles(geometry: GeometryProxy) -> some View {
    var points = [CGPoint]()
    var prevPoint: CGPoint?
    (0...5).forEach { i in
        let point: CGPoint
        if let prevPoint = prevPoint {
            point = CGPoint(x: prevPoint.x + 1, y: prevPoint.y)
        } else {
            point = .zero
            
        }
        points.append(point)
        prevPoint = point
    }
    return ForEach(points.indices, id: \.self) { i in
        let point = points[i]
        Circle()
            .offset(
                x: point.x,
                y: point.y
            )
    }
}

然后你可以像circles(geometry: geometry).foregroundColor(.green)

一样在正文中使用它

我正在使用 preferenceKey (CirclePointsKey) 的 reduce function 将所有坐标存储到一个点数组中。带有几何 reader 的叠加层将读取每个球的每个中间位置的位置。我将视图容器框架命名为 ballContainer 只是为了获得正确的相对位置。

这与您发布的曲线不完全相同,但您可以根据需要更改“path.addCurve”中的参数。

只有当至少有 2 个 preferenceKey 尝试设置新值时,才会调用 reduce 函数。通常用于设置新值,但在本例中我附加了每个值。

struct CirclePointsKey: PreferenceKey {
    typealias Value = [CGPoint]

    static var defaultValue: [CGPoint] = []

    static func reduce(value: inout [CGPoint], nextValue: () -> [CGPoint]) {
        value.append(contentsOf: nextValue())
    }
}

struct ExampleView: View {
    @State var points: [CGPoint] = []

    var body: some View {
        ZStack {
            ScrollView {
                ZStack {
                    Path { (path: inout Path) in

                        if let firstPoint = points.first {
                            path.move(to: firstPoint)
                            var lastPoint: CGPoint = firstPoint
                            for point in points.dropFirst() {
//                                path.addLine(to: point)
                                let isGoingRight = point.x < lastPoint.x
                                path.addCurve(to: point, control1: CGPoint(x: isGoingRight ? point.x : lastPoint.x,
                                                                           y: !isGoingRight ? point.y : lastPoint.y),
                                              control2: CGPoint(x: point.x, y: point.y))
                                lastPoint = point
                            }
                        }
                    }
                    .stroke(lineWidth: 2)
                    .foregroundColor(.white.opacity(0.5))

                    VStack {
                        VStack(spacing: 30) {
                            ForEach(Array(0...4).indices) { index in
                                ball
                                    .overlay(GeometryReader { geometry in
                                        Color.clear
                                            .preference(key: CirclePointsKey.self,
                                                        value: [CGPoint(x: geometry.frame(in: .named("ballContainer")).midX,
                                                                        y: geometry.frame(in: .named("ballContainer")).midY)])
                                    })
                                    .frame(maxWidth: .infinity,
                                           alignment: index.isMultiple(of: 2) ? .leading : .trailing)
                            }
                        }
                        .coordinateSpace(name: "ballContainer")
                        .onPreferenceChange(CirclePointsKey.self) { data in
                            points = data
                        }
                        Text("points:\n \(String(describing: points))")
                        Spacer()
                    }
                }
            }
        }
        .foregroundColor(.white)
        .background(Color.black.ignoresSafeArea())
    }

    @ViewBuilder
    var ball: some View {
        Circle()
            .fill(Color.gray)
            .frame(width: 70, height: 70, alignment: .center)
            .padding(10)
            .overlay(Circle()
                .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [5, 10, 10, 5]))
                .foregroundColor(.white)
                .padding(7)
            )
            .shadow(color: .white.opacity(0.7), radius: 10, x: 0.0, y: 0.0)
    }
}