-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 1 (5/5): ManimView SwiftUI preview, demo scene, and docs #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| extension ManimScene { | ||
| /// A short tour of Phase 1: create, shift, parallel rotate + scale, | ||
| /// morphing transforms, and fades. Used by the `ManimView` preview. | ||
| public static var demo: ManimScene { | ||
| ManimScene { scene in | ||
| let circle = Mobject.circle(radius: 1.2) | ||
| .stroke(.blue) | ||
| .fill(.blue, opacity: 0.5) | ||
|
|
||
| scene.play(.create(circle, duration: 1.2)) | ||
| scene.wait(0.3) | ||
| scene.play(.shift(circle, by: Vec2(-3, 0))) | ||
|
|
||
| let square = Mobject.square(sideLength: 2, at: Vec2(-3, 0)) | ||
| .stroke(.red) | ||
| .fill(.red, opacity: 0.5) | ||
| scene.play(.transform(circle, into: square, duration: 1.2)) | ||
| scene.wait(0.2) | ||
|
|
||
| let dot = Mobject.dot(at: Vec2(3, 1)) | ||
| let label = Mobject.triangle(radius: 0.8, at: Vec2(3, 1)) | ||
| .stroke(.green) | ||
| .fill(.green, opacity: 0.4) | ||
| scene.play(.fadeIn(dot, shift: Vec2(0, -0.5), duration: 0.6)) | ||
| scene.play(.create(label, duration: 0.8)) | ||
|
|
||
| scene.play( | ||
| .rotate(circle, by: .pi / 2, duration: 1), | ||
| .scale(circle, by: 1.3, duration: 1), | ||
| .shift(label, by: Vec2(0, -2), duration: 1) | ||
| ) | ||
| scene.wait(0.3) | ||
|
|
||
| let hexagon = Mobject.regularPolygon(sides: 6, radius: 1.2, at: Vec2(0, 0)) | ||
| .stroke(.purple) | ||
| .fill(.purple, opacity: 0.5) | ||
| scene.play( | ||
| .transform(circle, into: hexagon, duration: 1.2), | ||
| .fadeOut(dot, duration: 0.6), | ||
| .fadeOut(label, shift: Vec2(0, -1), duration: 0.8) | ||
| ) | ||
| scene.wait(0.4) | ||
| scene.play(.fadeOut(circle, shift: Vec2(0, 1), duration: 0.8)) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,223 @@ | ||||||||||||||||||||||||||
| #if canImport(SwiftUI) | ||||||||||||||||||||||||||
| import Foundation | ||||||||||||||||||||||||||
| import SwiftUI | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /// A SwiftUI player for a ``ManimScene``: renders the scene into a | ||||||||||||||||||||||||||
| /// `Canvas` and provides play/pause, restart, and scrubbing controls. | ||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||
| /// ```swift | ||||||||||||||||||||||||||
| /// import PicoManim | ||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||
| /// struct ContentView: View { | ||||||||||||||||||||||||||
| /// var body: some View { | ||||||||||||||||||||||||||
| /// ManimView(scene: .demo) | ||||||||||||||||||||||||||
| /// } | ||||||||||||||||||||||||||
| /// } | ||||||||||||||||||||||||||
| /// ``` | ||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||
| /// Because scenes are evaluated purely by time, the view can loop and | ||||||||||||||||||||||||||
| /// scrub freely; playback state is just an anchor time plus a clock. | ||||||||||||||||||||||||||
| public struct ManimView: View { | ||||||||||||||||||||||||||
| public var scene: ManimScene | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private let loops: Bool | ||||||||||||||||||||||||||
| private let showsControls: Bool | ||||||||||||||||||||||||||
| /// Visible scene area in scene units (width, height). The scene is | ||||||||||||||||||||||||||
| /// scaled uniformly to fit this frame inside the view. | ||||||||||||||||||||||||||
| private let frameSize: Vec2 | ||||||||||||||||||||||||||
| private let background: ManimColor | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @State private var isPlaying: Bool | ||||||||||||||||||||||||||
| /// Playhead position when `anchorDate` was set. | ||||||||||||||||||||||||||
| @State private var anchorTime: Double = 0 | ||||||||||||||||||||||||||
| /// Wall-clock moment playback (re)started; ignored while paused. | ||||||||||||||||||||||||||
| @State private var anchorDate = Date() | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| public init( | ||||||||||||||||||||||||||
| scene: ManimScene, | ||||||||||||||||||||||||||
| autoplays: Bool = true, | ||||||||||||||||||||||||||
| loops: Bool = true, | ||||||||||||||||||||||||||
| showsControls: Bool = true, | ||||||||||||||||||||||||||
| frameSize: Vec2 = Vec2(14.0 + 2.0 / 9.0, 8.0), | ||||||||||||||||||||||||||
| background: ManimColor = .background | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| self.scene = scene | ||||||||||||||||||||||||||
| self.loops = loops | ||||||||||||||||||||||||||
| self.showsControls = showsControls | ||||||||||||||||||||||||||
| self.frameSize = frameSize | ||||||||||||||||||||||||||
| self.background = background | ||||||||||||||||||||||||||
| self._isPlaying = State(initialValue: autoplays && scene.duration > 0) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| public var body: some View { | ||||||||||||||||||||||||||
| TimelineView(.animation(minimumInterval: nil, paused: !isPlaying)) { timeline in | ||||||||||||||||||||||||||
| let time = playhead(at: timeline.date) | ||||||||||||||||||||||||||
| VStack(spacing: 0) { | ||||||||||||||||||||||||||
| canvas(time: time) | ||||||||||||||||||||||||||
| if showsControls { | ||||||||||||||||||||||||||
| controls(time: time) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .onChange(of: timeline.date) { _, newDate in | ||||||||||||||||||||||||||
| if isPlaying && !loops && playhead(at: newDate) >= scene.duration { | ||||||||||||||||||||||||||
| anchorTime = scene.duration | ||||||||||||||||||||||||||
| isPlaying = false | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .background(uiColor(background)) | ||||||||||||||||||||||||||
| .onAppear { | ||||||||||||||||||||||||||
| // The struct (and its @State defaults) can be created well before | ||||||||||||||||||||||||||
| // the view is displayed; re-anchor the clock so autoplay doesn't | ||||||||||||||||||||||||||
| // start partway through the scene. | ||||||||||||||||||||||||||
| if isPlaying { | ||||||||||||||||||||||||||
| anchorDate = Date() | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // MARK: - Playback | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private func playhead(at date: Date) -> Double { | ||||||||||||||||||||||||||
| let total = scene.duration | ||||||||||||||||||||||||||
| guard total > 0 else { return 0 } | ||||||||||||||||||||||||||
| guard isPlaying else { return clamp(anchorTime, 0...total) } | ||||||||||||||||||||||||||
| let raw = anchorTime + date.timeIntervalSince(anchorDate) | ||||||||||||||||||||||||||
| if loops { | ||||||||||||||||||||||||||
| return raw.truncatingRemainder(dividingBy: total) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return Swift.min(raw, total) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private func togglePlayback(from time: Double) { | ||||||||||||||||||||||||||
| if isPlaying { | ||||||||||||||||||||||||||
| anchorTime = time | ||||||||||||||||||||||||||
| isPlaying = false | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| anchorTime = (!loops && time >= scene.duration) ? 0 : time | ||||||||||||||||||||||||||
| anchorDate = Date() | ||||||||||||||||||||||||||
| isPlaying = true | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private func seek(to time: Double) { | ||||||||||||||||||||||||||
| anchorTime = clamp(time, 0...Swift.max(scene.duration, 0)) | ||||||||||||||||||||||||||
| anchorDate = Date() | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // MARK: - Rendering | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private func canvas(time: Double) -> some View { | ||||||||||||||||||||||||||
| Canvas { context, size in | ||||||||||||||||||||||||||
| // The view's .background fills behind the canvas; no need to | ||||||||||||||||||||||||||
| // paint it again here. Guard degenerate sizes (including a | ||||||||||||||||||||||||||
| // caller-supplied non-positive frameSize) so scale stays finite. | ||||||||||||||||||||||||||
| guard size.width > 0, size.height > 0, frameSize.x > 0, frameSize.y > 0 else { return } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let scale = Swift.min(Double(size.width) / frameSize.x, Double(size.height) / frameSize.y) | ||||||||||||||||||||||||||
| let centerX = Double(size.width) / 2 | ||||||||||||||||||||||||||
| let centerY = Double(size.height) / 2 | ||||||||||||||||||||||||||
| func viewPoint(_ p: Vec2) -> CGPoint { | ||||||||||||||||||||||||||
| CGPoint(x: centerX + p.x * scale, y: centerY - p.y * scale) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for mobject in scene.snapshot(at: time) { | ||||||||||||||||||||||||||
| var path = Path() | ||||||||||||||||||||||||||
| for subpath in mobject.worldPath.subpaths where !subpath.curves.isEmpty { | ||||||||||||||||||||||||||
| path.move(to: viewPoint(subpath.curves[0].p0)) | ||||||||||||||||||||||||||
| for curve in subpath.curves { | ||||||||||||||||||||||||||
| path.addCurve( | ||||||||||||||||||||||||||
| to: viewPoint(curve.p1), | ||||||||||||||||||||||||||
| control1: viewPoint(curve.c1), | ||||||||||||||||||||||||||
| control2: viewPoint(curve.c2) | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| if subpath.isClosed { | ||||||||||||||||||||||||||
| path.closeSubpath() | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let fillAlpha = mobject.effectiveFillAlpha | ||||||||||||||||||||||||||
| if fillAlpha > 0.001 { | ||||||||||||||||||||||||||
| context.fill(path, with: .color(uiColor(mobject.fillColor, alpha: fillAlpha))) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| let strokeAlpha = mobject.effectiveStrokeAlpha | ||||||||||||||||||||||||||
| if strokeAlpha > 0.001, mobject.strokeWidth > 0, mobject.strokeEnd > mobject.strokeStart { | ||||||||||||||||||||||||||
| var strokePath = path | ||||||||||||||||||||||||||
| if mobject.strokeStart > 0 || mobject.strokeEnd < 1 { | ||||||||||||||||||||||||||
| // trimmedPath expects 0...1; clamp in case a custom | ||||||||||||||||||||||||||
| // rate function overshoots. | ||||||||||||||||||||||||||
| strokePath = path.trimmedPath( | ||||||||||||||||||||||||||
| from: CGFloat(clamp(mobject.strokeStart, 0...1)), | ||||||||||||||||||||||||||
| to: CGFloat(clamp(mobject.strokeEnd, 0...1)) | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+148
to
+155
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SwiftUI's We should clamp these values to
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in caf2a65: Generated by Claude Code |
||||||||||||||||||||||||||
| context.stroke( | ||||||||||||||||||||||||||
| strokePath, | ||||||||||||||||||||||||||
| with: .color(uiColor(mobject.strokeColor, alpha: strokeAlpha)), | ||||||||||||||||||||||||||
| style: StrokeStyle( | ||||||||||||||||||||||||||
| // 100 Manim stroke units = 1 scene unit. | ||||||||||||||||||||||||||
| lineWidth: CGFloat(mobject.strokeWidth / 100 * scale), | ||||||||||||||||||||||||||
| lineCap: .round, | ||||||||||||||||||||||||||
| lineJoin: .round | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .accessibilityLabel("Animation preview") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // MARK: - Controls | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private func controls(time: Double) -> some View { | ||||||||||||||||||||||||||
| HStack(spacing: 12) { | ||||||||||||||||||||||||||
| Button { | ||||||||||||||||||||||||||
| togglePlayback(from: time) | ||||||||||||||||||||||||||
| } label: { | ||||||||||||||||||||||||||
| Image(systemName: isPlaying ? "pause.fill" : "play.fill") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .buttonStyle(.plain) | ||||||||||||||||||||||||||
| .accessibilityLabel(isPlaying ? "Pause" : "Play") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Button { | ||||||||||||||||||||||||||
| seek(to: 0) | ||||||||||||||||||||||||||
| } label: { | ||||||||||||||||||||||||||
| Image(systemName: "gobackward") | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .buttonStyle(.plain) | ||||||||||||||||||||||||||
| .accessibilityLabel("Restart") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Slider( | ||||||||||||||||||||||||||
| value: Binding( | ||||||||||||||||||||||||||
| get: { time }, | ||||||||||||||||||||||||||
| set: { seek(to: $0) } | ||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||
| in: 0...Swift.max(scene.duration, 0.001) | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| .accessibilityLabel("Timeline") | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| Text(timeLabel(time)) | ||||||||||||||||||||||||||
| .font(.caption.monospacedDigit()) | ||||||||||||||||||||||||||
| .foregroundStyle(.secondary) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| .foregroundStyle(.white) | ||||||||||||||||||||||||||
| .padding(.horizontal, 12) | ||||||||||||||||||||||||||
| .padding(.vertical, 8) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private func timeLabel(_ time: Double) -> String { | ||||||||||||||||||||||||||
| String(format: "%.1fs / %.1fs", time, scene.duration) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| private func uiColor(_ color: ManimColor, alpha: Double? = nil) -> Color { | ||||||||||||||||||||||||||
| Color(red: color.red, green: color.green, blue: color.blue, opacity: alpha ?? color.alpha) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| #Preview("Demo scene") { | ||||||||||||||||||||||||||
| ManimView(scene: .demo) | ||||||||||||||||||||||||||
| .frame(width: 640, height: 420) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| #endif | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Initializing
@State private var anchorDate = Date()at the property declaration level means the date is captured when theManimViewstruct is first instantiated. In SwiftUI, view structs can be created well before they are actually displayed on screen (e.g., when pre-rendering in a navigation stack, tab view, or parent view update).When the view finally appears and starts playing,
anchorDatewill be in the past, causing the animation to instantly skip forward by that time difference (or skip the entire animation if the delay was longer than the duration).To fix this, we should reset
anchorDateto the current time when the view actually appears on screen.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in caf2a65 ("Address review: re-anchor clock on appear, canvas guards, trim clamps"): added the suggested
.onAppearthat re-anchorsanchorDatewhen autoplay is on, so a view instantiated before display doesn't start partway through (or past) the scene. Not unit-testable (needs a real SwiftUI lifecycle); noted in the PR description's test-strategy caveat and verified via the preview.Generated by Claude Code