♨️
SwiftUIで紐を表現する
概要
- Twitterで惚れ惚れするようなリアルな紐の表現を見かけたので、見様見真似で実装してみました。
 
前提
- 厳密な物理法則に従うものではなく紐っぽい動きを表現するような実装です。
 - また高校一年生レベルの物理(等加速度直線運動・フックの法則等)を使います。
 
GitHub
参考
- ベジェ曲線の算出方法。
 - Spring Animationの説明とプログラムの実装。
 - 単振動の説明(序盤のみ視聴)
 - 
[SwiftUI] .offset と .position の違い
- positionは親ビューに対しての座標となることに注意。
 - 各Viewの座標系を考慮すると話がややこしくなるので、基本的にGlogalの座標を使うようにZStackを随所で使用しています。
 
 - ViewのDragの実装。
 - 
gestureでlocationを使えるのはiOS 16+から。 - 複数のViewに対してのDrag Gestureの実装。
 - 紐の光るエフェクトの実装。
 - SwiftUIで動く破線の実装においてdashPhaseの指定部分の挙動が分からない
 
Twitterでの情報収集まとめ
- 
TimelineViewを使う。 
- 
SpriteKitは使っていない。 - Springを考慮している。
 
- 実装のイメージがつきやすい。
 - 
quadratic Bezier curve、つまり二次ベジェ曲線で表現している。 
- 実装のヒント。
 
- 中点の決め方でコードを参考にした。
 
// https://codesandbox.io/s/spring-rope-physics-pjk51h?file=/src/App.tsx:1751-1772
const distance = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
const decline = Math.max(0, 500 - distance);
const midpointX = (x1 + x2) / 2;
const midpointY = (y1 + y2) / 2 + decline;
- Springの参考に。
 
- Springの参考に。
 
紐の形のPathを作成
概要
- 今回紐の形を表現するPathとして
2次ベジェ曲線を使います。 - 正しい紐の形は
カテナリー曲線らしいのですが今回は対象外です。 - 
2次ベジェ曲線は下記のようにP0, P1, P2の三点によって描かれます。- 
A, P, Bは補助的に書いてあるだけなので無視してもらって大丈夫です。 
 - 
 

GitHub
実装
- 2次ベジェ曲線を描くには、SwiftUIのPathのaddQuadCurve(to:control:)を使います。
 
Path { path in
    path.move(to: pointP0)
    path.addQuadCurve(to: pointP2,
                      control: pointP1)
    path.addLine(to: pointP2)
}
- ここで
P0, P2はドラッグ操作等で動かせるものとし、この2点を使ってP1の座標を算出したいです。 - x座標は
P0とP2の中点で良さそうですが、y座標は紐の弛みを表現するために少しだけ工夫します。 - 厳密に計算できるのかもしれませんが、今回はそれらしい表現のできる値となるようにしています。
 
private let ropeLength: CGFloat = 400  // (1)
@State var pointP0: CGPoint = .init(x: 100, y: 100)
@State var pointP2: CGPoint = .init(x: 300, y: 100)
// P1: 制御点(Control Point)
var pointP1: CGPoint {
    let distance = sqrt(
        pow(pointP2.x - pointP0.x, 2) + pow(pointP2.y - pointP0.y, 2)
    )
    let decline = max(0, 400 - distance / 2)  // (2)
    
    return .init(x: (pointP0.x + pointP2.x) / 2,
                 y: (pointP0.y + pointP2.y) / 2 + decline)  // (3)
}
- 下記のような処理をしています。
- (1): 紐の長さを
400とし、あとの処理でP0とP2の距離がそれより短い場合に紐をたるませるようにします。 - (2): 中点からどれだけ下げるのかを計算します。
紐の長さ - (P0-P2間の距離 / 2)としています。 - (3): 最終的にP1は
P0-P2の中点から(2)の値を下げたものとなります。 
 - (1): 紐の長さを
 - これで紐のPathが決まったので、実際に
P0, P2を動かして確認してみます。 - 紐らしい形のパスの表現ができてますね。
 

ばねのアニメーション(減衰なし)
概要
- 紐の形はいい感じになりましたが、このままでは無機質な感じです。
 - リアルな表現のために
ばねのアニメーション(Spring Animation)を加えます。- Springという単語はSwiftのアニメーションのメソッド(例: spring(response:dampingFraction:blendDuration:))などで馴染みがあるかもしれませんが、そのままの意味で"ばね"のようなアニメーションのことですね。
 - 今回はこれを手計算で実装していきます。
 
 
GitHub
- SpringView.swift
 - 
SpringView+PointsManager.swift
- ※ dampingが0のとき
 
 
実装
- まず青い点を基準点として、そこからすこし離れた赤い点が単振動する動きを実装します。
- 青い点から赤い点にむかってバネが伸びているイメージですね。
 
 

- 赤い点の計算と描画方法に関しては以下の通りとします。
- (1): 
Timerをつかいフレームレート毎に制御点の座標の計算を行う。 - (2): 上記の結果を
TimelineViewを使い、同じフレームレートの間隔でビューを更新する。 
 - (1): 
 - 点の動きの計算ができるよう一定間隔で描画を行いたい、という理由でこのようにしています。
 
ZStack {
    // (2)
    TimelineView(.periodic(from: Date(), by: pointsManager.frameRate)) { context in
        // Views...
    }
}
.onAppear {
    pointsManager.startTimer()  // (1)
}
.onDisappear {
    pointsManager.stopTimer()
}
以下数式がでてくるのでアレルギーがなければどうぞ!
- 今回の説明はこの記事の通りです。
 - まずフレームレート毎の等加速度直線運動を考えます。
- 実際は単振動なので加速度は常に変化するのですが(元記事では明言がないですが)フレームレート毎の短い時間で近似的に等加速度直線運動で考えます。(間違っていたらご指摘ください)
 
 - 
フックの法則により、基準点よりxだけ離れたときにかかる力
Fs(Fs:= F_spring)は以下の通り。 
Fs = -k*x
x: 基準点からどれだけ離れているか。
k: バネ定数。
- またニュートンの運動方程式に物体にかかる力の合計を
Fとすると以下が成り立ちます。 
F = m*a
m: 質量。軽いほど単振動の動きが早くなります。
a: 加速度。速度の変化する度合いですね。
- 今回は
F = Fsなので、加速度aは下記の通りです。 
m*a = -k*x
-> a = -k*x/m
- よって現在の状態から
t秒後の速度v2は以下の通り。 
v2 = v1 + a*t
- また
t秒後の位置p2は以下となります。- (正直ここの計算があまり理解できていないです。v2はt秒間中に変化するよなあと思うのですが、わかる方がいれば教えていただけると助かります…!)
 
 
p2 = p1 + v2*t
- 以上をSwiftで表すとこのようになり、前述のような単振動が表現できます。
 
let offsetX = point.x - standardPoint.x  // 基準点からどれだけはなれているか
let fSpringX = spring.k * offsetX  // フックの法則
let ax = fSpringX / point.mass  // 加速度
point.vx = point.vx + ax * frameRate  // v2: frameRate秒後の点の速度
point.x = point.x + point.vx * frameRate  // p2: frameRate秒後の点の位置
ばねのアニメーション(減衰あり)
概要
- あとは実際のばねが徐々に止まっていくのを表現するため、上記の式に
減衰(Damping)を加えると、以下のような動きを表現できます。 

GitHub
実装
以下数式がでてくるのでアレルギーがなければどうぞ!
- 減衰の力
Fd(:= F_damping)は以下のように表せます。 
Fd = -d * v
d: 減衰定数。値が大きいほど減衰の度合いが大きいです。
v: 物体の速度
- よって加速度
aは以下のように表せます。 
F = Fs + Fd
またF = maなので
a = (Fs + Fd) / m
- 以上を前と同様にSwiftで表現すると以下のようになります。
 
let offsetX = point.x - standardPoint.x  // 基準点からどれだけはなれているか
let fSpringX = spring.k * offsetX  // フックの法則
let fDampingX = spring.d * point.vx  // 減衰
let ax = (fSpringX + fDampingX) / point.mass  // 加速度
point.vx = point.vx + ax * frameRate  // v2: frameRate秒後の点の速度
point.x = point.x + point.vx * frameRate  // p2: frameRate秒後の点の位置
紐の実装
概要
- 先程のばねのアニメーションを紐の形のPathに対して適用すると、いい感じの紐の表現ができるようになります。
 

GitHub
実装
- 
P1を基準とした点をcontrolPointと定義します。 - 紐のパスは
P0, controlPoint, P2で描画し、controlPointはP1を中心にばねのアニメーションを行います。 - 具体的には
ばねのアニメーション(減衰あり)の内容をy軸にも拡張し、これをcontrolPointに適用します。 
let offsetX = controlPoint.x - pointP1.x
let offsetY = controlPoint.y - pointP1.y
let fSpringX = spring.k * offsetX
let fSpringY = spring.k * offsetY
let fDampingX = spring.d * controlPoint.vx
let fDampingY = spring.d * controlPoint.vy
let ax = (fSpringX + fDampingX) / controlPoint.mass
let ay = (fSpringY + fDampingY) / controlPoint.mass
controlPoint.vx = controlPoint.vx + ax * frameRate
controlPoint.vy = controlPoint.vy + ay * frameRate
controlPoint.x = controlPoint.x + controlPoint.vx * frameRate
controlPoint.y = controlPoint.y + controlPoint.vy * frameRate
Tweetの実装
概要
- これまで説明した紐の実装を使って、最初のツイートのような表現を実装しています。
 
GitHub
実装
- 紐の実装以外は特別な所は少ないと思うので、コードを参照くださいませ。
 
紐の始点・終点をニコちゃんマークにあわせる実装
- ここのうまい方法が思いつかず、状態管理と
DragGestureでゴリゴリに実装しています。- いい方法があればコメントください!
 
 - 方針としては一つのView、今回でいうとZStackに全てのViewを配置し
DragGestureで諸々の処理を行っています。 
Discussion
素晴らしい記事です!
そういってもらえると嬉しいです 😽