docs(animation): state that effects on a shared property compose - #418
Merged
Merged
Conversation
Two effects touching the same property combine: opacity and scale multiply, translate and rotation add, everything else is last-written. That is what resolve_props_for_effects has always done by folding each bucket through AnimatedProperties::merge. Nothing about it was written anywhere an author would look, and the doc comment that claimed the opposite -- last effect wins -- went with the comments in #345, so the contract was nowhere at all. Composing is kept rather than switched to last-wins. Two presets on one property usually should compose: a pulse layered on a fade_in wants the product. Switching would change every existing scenario that stacks two effects, and would need a second syntax to ask for the composition back. rules/animations-compose.md says it, with the per-property table and the window-bounding recipe for the case where an author really does want one effect alone. The blind spot the issue names is real but narrow. For a composing property the neutral value is the identity, so skipping it and applying it are the same answer -- the guard there is an optimisation. For a last-written property it is not: blur: 0 is a value, not an absence, and `if other.blur > 0.001` could never take an element out of a blur an earlier bucket had put it in. blur, blur_x and blur_y now rest at -1.0 and merge on `>= 0.0`, which is what every other last-written property in the struct already did. Their readers all guard on `> 0.0`, so a negative resting value reads as no blur exactly as zero did.
LeadcodeDev
deleted the
docs/animations-compose-on-a-shared-property
branch
September 29, 2026 08:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #322.
The decision
Compose, and write it down. Two presets on one property usually should
compose — a
pulselayered on afade_inwants the product, not the second onewinning. Switching to last-wins would change every existing scenario that stacks
two effects, and would need a second syntax to ask for the composition back.
No rendering changes, except the narrow blur fix below.
What was actually missing
resolve_props_for_effectsfolds each bucket throughAnimatedProperties::merge,which has always composed. Nothing about that was written anywhere an author would
look, and the doc comment that claimed the opposite — "the last effect wins" —
went with the comments in #345. So the contract was nowhere at all.
rules/animations-compose.mdnow states it:opacity,scale_x,scale_ytranslate_x,translate_y,rotation,rotate_x,rotate_yplus the grouping rule (presets resolve together, keyframes resolve together) and
the window-bounding recipe for an author who really does want one effect alone.
The blind spot is real, but narrower than it reads
The issue notes that a bucket resolving a property to exactly its neutral value is
skipped, so an animation can never take a property back.
For a composing property that is not a defect:
1is the identity for aproduct and
0for a sum, so skipping the neutral value and applying it are thesame answer. The guard is an optimisation.
a_neutral_value_in_a_composing_property_is_a_no_op_by_arithmeticpins that.For a last-written property it is a defect — and it applied to exactly three
fields. Every other one in the struct already rests at a negative sentinel, so
"animated to zero" wins over an earlier value.
blur,blur_xandblur_yrestedat
0.0and merged on> 0.001, so nothing could ever take an element out of ablur an earlier bucket had put it in.
They now rest at
-1.0and merge on>= 0.0. Every reader already guards on> 0.0, so a negative resting value reads as no blur exactly as zero did.Tests
Five on
mergedirectly. Restoring the> 0.001guard failsa_later_bucket_can_take_blur_back_to_zero:Gate
cargo fmt --all --checkclean ·cargo clippy --workspace --all-targets --features rustmotion/studio -D warningsclean ·cargo test --workspace1802 passed.