♨️
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
素晴らしい記事です!
そういってもらえると嬉しいです 😽