Transforms
This page kicks off Advanced Topics, and it's worth taking slowly — nothing here is hard to use, but the underlying geometry takes a moment to click. Let's ease into it.
You've actually crossed paths with transform twice already, without quite meeting it properly. Back on Positioning Views, you learned that changing it never triggers a re-layout, since it changes how a View is drawn without changing what Layout thinks its size is. On User Input, you saw hit-detection account for it too. Now it's time for a proper introduction — first in 2D, then, with a Camera along for the ride, in 3D.
Use the right slider to change the camera distance from the View, which changes the intensity of the perspective used to render it.
Affine transforms
A View's transform can be set whenever you like, and takes effect immediately as long as the View is visible:
import io.nacular.doodle.core.View
import io.nacular.doodle.core.center
import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.times
fun affineTransform(view: View) {
//sampleStart
view.transform = Identity
view.transform = Identity.rotateX(around = view.center, by = 45 * degrees)
//sampleEnd
}
Every View starts out with Identity — the transform that changes precisely nothing. Setting transform back to Identity is how you clear one away:
import io.nacular.doodle.core.View
import io.nacular.doodle.drawing.AffineTransform.Companion.Identity
fun identityTransform(view: View) {
//sampleStart
// Scale view by 2 in x and y
view.transform = Identity.scale(2.0, 2.0)
// ...
// Remove all transform effects
view.transform = Identity
//sampleEnd
}
Multiple AffineTransforms chain together into one, with the result behaving exactly as though each operation happened one after another:
import io.nacular.doodle.drawing.AffineTransform
import io.nacular.doodle.geometry.Point
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.Angle.Companion.radians
import io.nacular.measured.units.times
import kotlin.math.PI
fun affineTransformChaining(transform: AffineTransform) {
//sampleStart
transform.
rotateY(by = 45 * degrees, around = Point( 2, 4)).
rotateX(by = PI * radians, around = Point(10, 3)).
//...
flipHorizontally()
//sampleEnd
}
As you saw on Layout, none of this ever triggers a re-layout — transforms are cheap enough to change every single frame, which is exactly what makes them so useful for animation.
Cameras
Here's the catch with AffineTransform: it always keeps parallel lines parallel. Fine for 2D, but not how perspective actually looks in the real world — picture two train tracks converging toward the horizon. No amount of rotating or scaling will ever produce that convergence; you need a transformation that's allowed to break the "parallel stays parallel" rule. That's exactly what a Camera brings to the table.
Every View has one, quietly combining with transform to produce the final result. A Camera boils down to two properties:
position— relative to the View's parent, this is where the vanishing point sits.distance— roughly, how far away the "eye" looking at the scene is. A larger distance gives a subtler, flatter perspective (like a telephoto lens); a smaller one exaggerates it (like a wide-angle one, up close).
In the live demo, drag the camera to reposition it, and resize it (or use the slider) to change its distance — watch how the perspective gets more or less dramatic as you do.
Sharing cameras
A View's Camera position is relative to its parent, which has a lovely side effect: Views sharing both a parent and a Camera appear to share one unified 3D space — as if they're sitting together in the same room, rather than each drifting in its own private one.
Since a Camera's position is parent-relative, sharing one only produces a unified 3D space for Views with the same parent.
Two-sided views
A View renders onto a flat, infinite plane — picture a pane of glass. Flip or rotate that pane far enough, and you're suddenly looking at it from behind, the way you'd catch mirrored text peeking through the back of a piece of paper held up to a window.
Doodle won't hand you a distinct "back" for a flipped View automatically — a playing card with two sides is something you build yourself, by tracking which way the card is currently facing and drawing the matching side. The check itself works out the View's current surface normal from its transform and Camera, and re-renders the moment either one changes:
import io.nacular.doodle.core.View
fun View.twoSidedView() {
//sampleStart
// Example of how one might determine whether a View is facing the user.
// This takes the cross product of 2 vectors on the View's surface and
// applies its transformation and camera.
val points = (camera.projection * transform).invoke(bounds.points.take(3))
val faceUp = (points[1] - points[0] cross points[2] - points[1]).z > 0.0
//sampleEnd
}
Recap
- A View's
transformchanges how it's drawn and hit-tested, without ever affecting what Layout thinks its size or position is — so it's cheap enough to animate freely. AffineTransformcovers 2D: translation, scaling, rotation, chained together. It always keeps parallel lines parallel, so it can't produce realistic perspective.- A
Camera(position+distance) adds that perspective, and combines withtransformfor the final 3D effect. - Views sharing a
Cameraand a parent appear to share one 3D space. - A two-sided View (like a playing card) is something you build yourself, by tracking which way it's currently facing.
Next: head to Animation to see transform (and other properties) change smoothly over time.