如何使用 swiftUI 沿自定义路径移动视图/形状?

Posted

技术标签:

【中文标题】如何使用 swiftUI 沿自定义路径移动视图/形状?【英文标题】:How to move a view/shape along a custom path with swiftUI? 【发布时间】:2020-03-04 07:38:51 【问题描述】:

似乎没有一种直观的方式可以沿自定义路径移动视图/形状,尤其是弯曲路径。我已经找到了几个 UIKit 库,它们允许视图在 Bézier 路径上移动(DKChainableAnimationKit、TweenKit、Sica 等),但我对使用 UIKit 不太满意,并且一直遇到错误。

目前使用 swiftUI,我正在手动执行此操作:

import SwiftUI
struct ContentView: View 
    @State var moveX = true
    @State var moveY = true
    @State var moveX2 = true
    @State var moveY2 = true
    @State var rotate1 = true
    var body: some View 
        ZStack
            Circle().frame(width:50, height:50)
                .offset(x: moveX ? 0:100, y: moveY ? 0:100)
                .animation(Animation.easeInOut(duration:1).delay(0))
                .rotationEffect(.degrees(rotate1 ? 0:350))
                .offset(x: moveX2 ? 0:-100, y: moveY2 ? 0:-200)
                .animation(Animation.easeInOut(duration:1).delay(1))

                .onAppear()
                    self.moveX.toggle();
                    self.moveY.toggle();
                    self.moveX2.toggle();
                    self.moveY2.toggle();
                    self.rotate1.toggle();
                    //    self..toggle()
            
        
 

它在某种程度上完成了工作,但灵活性受到严重限制,并且复合延迟很快就会变得一团糟。

如果有人知道我如何获得自定义视图/形状以沿着以下路径行进,我将不胜感激。

  Path  path in
        path.move(to: CGPoint(x: 200, y: 100))
        path.addQuadCurve(to: CGPoint(x: 230, y: 200), control: CGPoint(x: -100, y: 300))
        path.addQuadCurve(to: CGPoint(x: 90, y: 400), control: CGPoint(x: 400, y: 130))
        path.addLine(to: CGPoint(x: 90, y: 600))
    
    .stroke()

我设法找到的最接近的解决方案是在 SwiftUILab 上,但完整的教程似乎只对付费订阅者开放。

类似这样的:

【问题讨论】:

这是我第一次看到“不习惯使用 UIKit”的人。 SwiftUI 真的接管了... 哈哈,是的,我才开始学习 ios 开发,因为 swiftUI 真的让事情变得如此简单。我对 swiftUI 越熟悉,UIKit 就越陌生。 你在这里看到这个答案了吗? ***.com/questions/56879693/… 实际上你需要SwiftUI-Lab Examples的Example9中的FollowEffect原样(或稍作修改) 它与 UIKit、SwiftUI 或任何其他 UI 无关......了解参数曲线,然后使用您喜欢的任何编程语言实现您的知识。正确答案(尤其是参数曲线组合)超出了简单答案的范围。有很多不同的问题要回答,一一问。 【参考方案1】:

好的,这不简单,但我想帮忙......

在下一个 sn-p(macOS 应用程序)中,您可以看到可以根据需要进行调整的基本元素。

为简单起见,我选择简单的参数曲线,如果您喜欢使用更复杂的(复合)曲线,您必须解决如何将每个段的部分 t(参数)映射到整个曲线的复合 t(和相同必须在部分沿轨道距离与复合轨道沿轨道距离之间进行映射)。

为什么会出现这样的并发症?

飞机位移(恒速)所需的沿航迹距离与参数化曲线定义所依赖的曲线参数t之间存在非线性关系。

先看看结果

接下来看看它是如何实现的。您需要研究此代码,并在必要时研究参数曲线的定义和行为方式。

//
//  ContentView.swift
//  tmp086
//
//  Created by Ivo Vacek on 11/03/2020.
//  Copyright © 2020 Ivo Vacek. All rights reserved.
//

import SwiftUI
import Accelerate

protocol ParametricCurve 
    var totalArcLength: CGFloat  get 
    func point(t: CGFloat)->CGPoint
    func derivate(t: CGFloat)->CGVector
    func secondDerivate(t: CGFloat)->CGVector
    func arcLength(t: CGFloat)->CGFloat
    func curvature(t: CGFloat)->CGFloat


extension ParametricCurve 
    func arcLength(t: CGFloat)->CGFloat 
        var tmin: CGFloat = .zero
        var tmax: CGFloat = .zero
        if t < .zero 
            tmin = t
         else 
            tmax = t
        
        let quadrature = Quadrature(integrator: .qags(maxIntervals: 8), absoluteTolerance: 5.0e-2, relativeTolerance: 1.0e-3)
        let result = quadrature.integrate(over: Double(tmin) ... Double(tmax))  _t in
            let dp = derivate(t: CGFloat(_t))
            let ds = Double(hypot(dp.dx, dp.dy)) //* x
            return ds
        
        switch result 
        case .success(let arcLength, _/*, let e*/):
            //print(arcLength, e)
            return t < .zero ? -CGFloat(arcLength) : CGFloat(arcLength)
        case .failure(let error):
            print("integration error:", error.errorDescription)
            return CGFloat.nan
        
    
    func curveParameter(arcLength: CGFloat)->CGFloat 
        let maxLength = totalArcLength == .zero ? self.arcLength(t: 1) : totalArcLength
        guard maxLength > 0 else  return 0 
        var iteration = 0
        var guess: CGFloat = arcLength / maxLength

        let maxIterations = 10
        let maxErr: CGFloat = 0.1

        while (iteration < maxIterations) 
            let err = self.arcLength(t: guess) - arcLength
            if abs(err) < maxErr  break 
            let dp = derivate(t: guess)
            let m = hypot(dp.dx, dp.dy)
            guess -= err / m
            iteration += 1
        

        return guess
    
    func curvature(t: CGFloat)->CGFloat 
        /*
                    x'y" - y'x"
        κ(t)  = --------------------
                 (x'² + y'²)^(3/2)
         */
        let dp = derivate(t: t)
        let dp2 = secondDerivate(t: t)
        let dpSize = hypot(dp.dx, dp.dy)
        let denominator = dpSize * dpSize * dpSize
        let nominator = dp.dx * dp2.dy - dp.dy * dp2.dx

        return nominator / denominator
    


struct Bezier3: ParametricCurve 

    let p0: CGPoint
    let p1: CGPoint
    let p2: CGPoint
    let p3: CGPoint

    let A: CGFloat
    let B: CGFloat
    let C: CGFloat
    let D: CGFloat
    let E: CGFloat
    let F: CGFloat
    let G: CGFloat
    let H: CGFloat


    public private(set) var totalArcLength: CGFloat = .zero

    init(from: CGPoint, to: CGPoint, control1: CGPoint, control2: CGPoint) 
        p0 = from
        p1 = control1
        p2 = control2
        p3 = to
        A = to.x - 3 * control2.x + 3 * control1.x - from.x
        B = 3 * control2.x - 6 * control1.x + 3 * from.x
        C = 3 * control1.x - 3 * from.x
        D = from.x
        E = to.y - 3 * control2.y + 3 * control1.y - from.y
        F = 3 * control2.y - 6 * control1.y + 3 * from.y
        G = 3 * control1.y - 3 * from.y
        H = from.y
        // mandatory !!!
        totalArcLength = arcLength(t: 1)
    

    func point(t: CGFloat)->CGPoint 
        let x = A * t * t * t + B * t * t + C * t + D
        let y = E * t * t * t + F * t * t + G * t + H
        return CGPoint(x: x, y: y)
    

    func derivate(t: CGFloat)->CGVector 
        let dx = 3 * A * t * t + 2 * B * t + C
        let dy = 3 * E * t * t + 2 * F * t + G
        return CGVector(dx: dx, dy: dy)
    

    func secondDerivate(t: CGFloat)->CGVector 
        let dx = 6 * A * t + 2 * B
        let dy = 6 * E * t + 2 * F
        return CGVector(dx: dx, dy: dy)
    



class AircraftModel: ObservableObject 
    let track: ParametricCurve
    let path: Path
    var aircraft: some View 
        let t = track.curveParameter(arcLength: alongTrackDistance)
        let p = track.point(t: t)
        let dp = track.derivate(t: t)
        let h = Angle(radians: atan2(Double(dp.dy), Double(dp.dx)))
        return Text("?").font(.largeTitle).rotationEffect(h).position(p)
    
    @Published var alongTrackDistance = CGFloat.zero
    init(from: CGPoint, to: CGPoint, control1: CGPoint, control2: CGPoint) 
        track = Bezier3(from: from, to: to, control1: control1, control2: control2)
        path = Path( (path) in
            path.move(to: from)
            path.addCurve(to: to, control1: control1, control2: control2)
        )
    


struct ContentView: View 
    @ObservedObject var aircraft = AircraftModel(from: .init(x: 0, y: 0), to: .init(x: 500, y: 600), control1: .init(x: 600, y: 100), control2: .init(x: -300, y: 400))

    var body: some View 
        VStack 
            ZStack 
                aircraft.path.stroke(style: StrokeStyle( lineWidth: 0.5))
                aircraft.aircraft
            
            Slider(value: $aircraft.alongTrackDistance, in: (0.0 ... aircraft.track.totalArcLength)) 
                Text("along track distance")
            .padding()
            Button(action: 
                // fly (to be implemented :-))
            ) 
                Text("Fly!")
            .padding()
        
    



struct ContentView_Previews: PreviewProvider 
    static var previews: some View 
        ContentView()
    

如果您担心如何实现“动画”飞机运动,SwiftUI 动画不是解决方案。您必须以编程方式移动飞机。

你必须导入

import Combine

添加到模型

@Published var flying = false
var timer: Cancellable? = nil

func fly() 
    flying = true
    timer = Timer
        .publish(every: 0.02, on: RunLoop.main, in: RunLoop.Mode.default)
        .autoconnect()
        .sink(receiveValue:  (_) in
            self.alongTrackDistance += self.track.totalArcLength / 200.0
            if self.alongTrackDistance > self.track.totalArcLength 
                self.timer?.cancel()
                self.flying = false
            
        )

并修改按钮

Button(action: 
    self.aircraft.fly()
) 
    Text("Fly!")
.disabled(aircraft.flying)
.padding()

终于找到了

【讨论】:

感谢您的帮助,比我希望的要复杂得多,但我想一旦我更好地掌握了 swift,我会更好地理解它。 swiftui lab 的文章暂时完成了我正在寻找的工作。 此答案应标记为正确。谢谢你教我正交。【参考方案2】:

试试这个:

但是:请注意:这不是在预览中运行,您必须在模拟器/设备上运行

struct MyShape: Shape 

    func path(in rect: CGRect) -> Path 
        let path =

        Path  path in
            path.move(to: CGPoint(x: 200, y: 100))
            path.addQuadCurve(to: CGPoint(x: 230, y: 200), control: CGPoint(x: -100, y: 300))
            path.addQuadCurve(to: CGPoint(x: 90, y: 400), control: CGPoint(x: 400, y: 130))
            path.addLine(to: CGPoint(x: 90, y: 600))
        


        return path
    


struct ContentView: View 

    @State var x: CGFloat = 0.0

    var body: some View 
        MyShape()
            .trim(from: 0, to: x)
            .stroke(lineWidth: 10)
            .frame(width: 200, height: 200)
            .onAppear() 
                withAnimation(Animation.easeInOut(duration: 3).delay(0.5)) 
                    self.x = 1
                
        
    

【讨论】:

我也试过了,但这只会修剪形状/路径,它不会移动任何上面的东西。例如,如果我有一个飞机的系统图标,并且想将它移动/同步到路径,我该怎么做?【参考方案3】:

来自 user3441734 的解决方案非常通用且优雅。读者将受益于每一秒思考ParametricCurve 及其弧长和曲率。这是我发现的唯一一种可以在移动时重新定向移动物体(飞机)以指向前方的方法。

Asperi 还在Is it possible to animate view on a certain Path in SwiftUI 中发布了一个有用的解决方案

这是一个用更少的东西做更少的解决方案。它确实使用了 SwiftUI 动画,这是喜忧参半。 (例如,您可以获得更多动画曲线的选择,但在动画完成时您不会收到通知或回调。)它的灵感来自 Asperi 在Problem animating with animatableData in SwiftUI 中的回答。

import SwiftUI

// Use https://www.desmos.com/calculator/cahqdxeshd to design Beziers.

// Pick a simple example path.
fileprivate let W = UIScreen.main.bounds.width
fileprivate let H = UIScreen.main.bounds.height

fileprivate let p1 = CGPoint(x: 50, y: H - 50)
fileprivate let p2 = CGPoint(x: W - 50, y: 50)

fileprivate var samplePath : Path 
    let c1 = CGPoint(x: p1.x, y: (p1.y + p2.y)/2)
    let c2 = CGPoint(x: p2.x, y: (p1.y + p2.y)/2)

    var result = Path()
    result.move(to: p1)
    result.addCurve(to: p2, control1: c1, control2: c2)
    return result


// This View's position follows the Path.
struct SlidingSpot : View 
    let path    : Path
    let start   : CGPoint
    let duration: Double = 1

    @State var isMovingForward = false

    var tMax : CGFloat  isMovingForward ? 1 : 0   // Same expressions,
    var opac : Double   isMovingForward ? 1 : 0   // different meanings.

    var body: some View 
        VStack 
            Circle()
            .frame(width: 30)

            // Asperi is correct that this Modifier must be separate. 
            .modifier(Moving(time: tMax, path: path, start: start))

            .animation(.easeInOut(duration: duration), value: tMax)
            .opacity(opac)

            Button 
                isMovingForward = true

                // Sneak back to p1. This is a code smell.
                DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.1) 
                    isMovingForward = false
                
             label: 
                Text("Go")
            
        
    


// Minimal modifier. 
struct Moving: AnimatableModifier 
    var time : CGFloat  // Normalized from 0...1.
    let path : Path
    let start: CGPoint  // Could derive from path.

    var animatableData: CGFloat 
        get  time 
        set  time = newValue 
    

    func body(content: Content) -> some View 
        content
        .position(
            path.trimmedPath(from: 0, to: time).currentPoint ?? start
        )
    


struct ContentView: View 
    var body: some View 
        SlidingSpot(path: samplePath, start: p1)
    

【讨论】:

以上是关于如何使用 swiftUI 沿自定义路径移动视图/形状?的主要内容,如果未能解决你的问题,请参考以下文章

如何在 SwiftUI 中停止快速移动视图的频闪

导航时如何在 SwiftUI 中使用自定义视图转换?

如何在 SwiftUI 视图中使用自定义 UI 控件,例如 Button?

如何滚动滚动视图以使 TextField 在 SwiftUI 中移动到键盘上方?

如何在 SwiftUI WidgetKit 占位符中自定义占位符外观?

如何在使用自定义用户代理的 SwiftUI 中创建 Safari 视图?