Commit 10189a0c authored by Anders Hartvoll Ruud's avatar Anders Hartvoll Ruud Committed by Commit Bot

[scroll-animations] Handle dynamically changing @scroll-timelines

We are currently able to create new CSSAnimations linked to
CSSScrollTimelines, but we can not change the timeline of a
CSSAnimation that is already running.

There are three types of changes that can trigger a timeline change
for a running animation:

 1. When a @scroll-timeline rule is inserted, either via DOM
    mutation, or via a change in @media query evaluation.
 2. When the computed value of 'animation-timeline' changes.
 3. When the elements referenced by a @scroll-timeline rule changes,
    for example 'source:selector(#foo)' references an element with
    #foo, hence we need a new timeline if #foo is reassigned to
    point to a different element.

This CL implements "timeline change detection" in CSSAnimations,
which can discover whether a new timeline is needed or not.
This detection runs whenever there's a non-animation-style-change,
in other words, whenever something is marked for regular style
style recalc, we will check if the timeline currently associated with
an animation is up-to-date, and create a new one if needed.

In this CL, only Item 1 in the above list is fully solved. However,
since this adds the general capability to reconsider previous timeline
choices upon style recalc, it also lays the groundwork for solving
Item 2 & 3.

InertEffect makes things a bit awkward, as usual: we basically have to
predict the effect setTimeline will have on the current time of the
Animation, without actually calling setTimeline. Hence there's a
rather complicated if-statement which makes this prediction. We should
ideally try to get rid of InertEffect and do the setTimeline call
right away.

In the WPT I'm also trying to cover both the result produced by
InertEffect, _and_ the result produced by setTimeline. Hence each test
is run twice: once for the result produced the same frame the style
recalc took place, and once again after scrolling a bit.

Bug: 1074052
Change-Id: Ida36a00eb91e26b493c1e70c20dc7b23be7c9a75
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2335282
Commit-Queue: Anders Hartvoll Ruud <andruud@chromium.org>
Reviewed-by: default avatarKevin Ellis <kevers@chromium.org>
Cr-Commit-Position: refs/heads/master@{#821697}
parent 559a9f80
......@@ -793,7 +793,7 @@ void Animation::setTimeline(AnimationTimeline* timeline) {
if (old_current_time) {
reset_current_time_on_resume_ = true;
start_time_ = base::nullopt;
hold_time_ = old_current_time.value();
SetHoldTimeAndPhase(old_current_time, TimelinePhase::kInactive);
} else if (PendingInternal()) {
start_time_ = boundary_time;
}
......
......@@ -221,6 +221,9 @@ class CORE_EXPORT Animation : public EventTargetWithInlineData,
// This should only be used for CSS
void Unpause();
bool ResetsCurrentTimeOnResume() const {
return reset_current_time_on_resume_;
}
void SetOutdated();
bool Outdated() { return outdated_; }
......
......@@ -71,6 +71,7 @@ class UpdatedCSSAnimation {
const InertEffect& effect,
Timing specified_timing,
StyleRuleKeyframes* style_rule,
AnimationTimeline* timeline,
const Vector<EAnimPlayState>& play_state_list)
: index(index),
animation(animation),
......@@ -78,12 +79,14 @@ class UpdatedCSSAnimation {
specified_timing(specified_timing),
style_rule(style_rule),
style_rule_version(this->style_rule->Version()),
timeline(timeline),
play_state_list(play_state_list) {}
void Trace(Visitor* visitor) const {
visitor->Trace(animation);
visitor->Trace(effect);
visitor->Trace(style_rule);
visitor->Trace(timeline);
}
wtf_size_t index;
......@@ -92,6 +95,7 @@ class UpdatedCSSAnimation {
Timing specified_timing;
Member<StyleRuleKeyframes> style_rule;
unsigned style_rule_version;
Member<AnimationTimeline> timeline;
Vector<EAnimPlayState> play_state_list;
};
......@@ -139,10 +143,11 @@ class CORE_EXPORT CSSAnimationUpdate final {
const InertEffect& effect,
const Timing& specified_timing,
StyleRuleKeyframes* style_rule,
AnimationTimeline* timeline,
const Vector<EAnimPlayState>& play_state_list) {
animations_with_updates_.push_back(
UpdatedCSSAnimation(index, animation, effect, specified_timing,
style_rule, play_state_list));
style_rule, timeline, play_state_list));
suppressed_animations_.insert(animation);
}
void UpdateCompositorKeyframes(Animation* animation) {
......
......@@ -432,11 +432,9 @@ AnimationTimeDelta IterationElapsedTime(const AnimationEffect& effect,
return iteration_duration * (iteration_boundary - iteration_start);
}
CSSScrollTimeline* CreateCSSScrollTimeline(Element* element,
StyleRuleScrollTimeline* rule) {
if (!rule)
return nullptr;
CSSScrollTimeline::Options options(element, *rule);
CSSScrollTimeline* CreateCSSScrollTimeline(
Element* element,
const CSSScrollTimeline::Options& options) {
if (!options.IsValid())
return nullptr;
auto* scroll_timeline =
......@@ -452,7 +450,16 @@ CSSScrollTimeline* CreateCSSScrollTimeline(Element* element,
AnimationTimeline* ComputeTimeline(Element* element,
const StyleNameOrKeyword& timeline_name,
StyleRuleScrollTimeline* rule) {
StyleRuleScrollTimeline* rule,
AnimationTimeline* existing_timeline) {
// TODO(crbug.com/1141836): Implement sticky timelines properly. For now just
// ignore animation-timeline whenever a regular (non-CSS) ScrollTimeline is
// present.
if (existing_timeline && existing_timeline->IsScrollTimeline() &&
!existing_timeline->IsCSSScrollTimeline()) {
return existing_timeline;
}
if (timeline_name.IsKeyword()) {
if (timeline_name.GetKeyword() == CSSValueID::kAuto)
return &element->GetDocument().Timeline();
......@@ -460,7 +467,16 @@ AnimationTimeline* ComputeTimeline(Element* element,
return nullptr;
}
if (rule) {
if (auto* timeline = CreateCSSScrollTimeline(element, rule))
CSSScrollTimeline::Options options(element, *rule);
// When the incoming options match the existing timeline, we can continue
// to use the existing timeline, since creating a new timeline from
// the options would just yield an identical timeline.
if (auto* timeline = DynamicTo<CSSScrollTimeline>(existing_timeline)) {
if (timeline->Matches(options))
return existing_timeline;
}
if (auto* timeline = CreateCSSScrollTimeline(element, options))
return timeline;
}
return nullptr;
......@@ -713,22 +729,46 @@ void CSSAnimations::CalculateAnimationUpdate(CSSAnimationUpdate& update,
toggle_pause_state = true;
}
// TODO(crbug.com/1097053): Support updating timelines.
bool will_be_playing =
toggle_pause_state ? animation->Paused() : animation->Playing();
AnimationTimeline* timeline = existing_animation->Timeline();
if (!is_animation_style_change) {
timeline = ComputeTimeline(&element, timeline_name,
scroll_timeline_rule, timeline);
}
if (keyframes_rule != existing_animation->style_rule ||
keyframes_rule->Version() !=
existing_animation->style_rule_version ||
existing_animation->specified_timing != specified_timing ||
is_paused != was_paused || logical_property_mapping_change) {
is_paused != was_paused || logical_property_mapping_change ||
timeline != existing_animation->Timeline()) {
DCHECK(!is_animation_style_change);
base::Optional<TimelinePhase> inherited_phase;
base::Optional<double> inherited_time;
if (timeline) {
inherited_phase = base::make_optional(timeline->Phase());
inherited_time = animation->UnlimitedCurrentTime();
if (will_be_playing &&
((timeline != existing_animation->Timeline()) ||
animation->ResetsCurrentTimeOnResume())) {
if (!timeline->IsMonotonicallyIncreasing())
inherited_time = timeline->CurrentTimeSeconds();
}
}
update.UpdateAnimation(
existing_animation_index, animation,
*MakeGarbageCollected<InertEffect>(
CreateKeyframeEffectModel(resolver, animating_element,
element, &style, parent_style, name,
keyframe_timing_function.get(), i),
timing, is_paused, animation->UnlimitedCurrentTime(),
base::nullopt),
specified_timing, keyframes_rule,
timing, is_paused, inherited_time, inherited_phase),
specified_timing, keyframes_rule, timeline,
animation_data->PlayStateList());
if (toggle_pause_state)
update.ToggleAnimationIndexPaused(existing_animation_index);
......@@ -736,7 +776,8 @@ void CSSAnimations::CalculateAnimationUpdate(CSSAnimationUpdate& update,
} else {
DCHECK(!is_animation_style_change);
AnimationTimeline* timeline =
ComputeTimeline(&element, timeline_name, scroll_timeline_rule);
ComputeTimeline(&element, timeline_name, scroll_timeline_rule,
nullptr /* existing_timeline */);
base::Optional<TimelinePhase> inherited_phase;
base::Optional<double> inherited_time;
if (timeline) {
......@@ -868,6 +909,8 @@ void CSSAnimations::MaybeApplyPendingUpdate(Element* element) {
effect->SetModel(entry.effect->Model());
effect->UpdateSpecifiedTiming(entry.effect->SpecifiedTiming());
}
if (entry.animation->timeline() != entry.timeline)
entry.animation->setTimeline(entry.timeline);
running_animations_[entry.index]->Update(entry);
}
......
......@@ -129,6 +129,8 @@ class CORE_EXPORT CSSAnimations final {
style_rule_version(new_animation.style_rule_version),
play_state_list(new_animation.play_state_list) {}
AnimationTimeline* Timeline() const { return animation->timeline(); }
void Update(UpdatedCSSAnimation update) {
DCHECK_EQ(update.animation, animation);
style_rule = update.style_rule;
......
......@@ -165,4 +165,12 @@ CSSScrollTimeline::CSSScrollTimeline(Document* document, const Options& options)
DCHECK(options.IsValid());
}
bool CSSScrollTimeline::Matches(const Options& options) const {
DCHECK(options.offsets_);
return (scrollSource() == options.source_) &&
(GetOrientation() == options.direction_) &&
(ScrollOffsetsEqual(*options.offsets_)) &&
(GetTimeRange() == options.time_range_);
}
} // namespace blink
......@@ -38,6 +38,8 @@ class CORE_EXPORT CSSScrollTimeline : public ScrollTimeline {
CSSScrollTimeline(Document*, const Options&);
bool Matches(const Options&) const;
// AnimationTimeline implementation.
bool IsCSSScrollTimeline() const override { return true; }
};
......
......@@ -186,6 +186,7 @@ ScrollTimeline::ScrollTimeline(
orientation_(orientation),
scroll_offsets_(scroll_offsets),
time_range_(time_range) {
DCHECK(scroll_offsets_);
if (resolved_scroll_source_) {
ScrollTimelineSet& set = GetScrollTimelineSet();
if (!set.Contains(resolved_scroll_source_)) {
......@@ -279,6 +280,19 @@ AnimationTimeline::PhaseAndTime ScrollTimeline::CurrentPhaseAndTime() {
timeline_state_snapshotted_.current_time};
}
bool ScrollTimeline::ScrollOffsetsEqual(
const HeapVector<Member<ScrollTimelineOffset>>& other) const {
DCHECK(scroll_offsets_);
if (scroll_offsets_->size() != other.size())
return false;
size_t size = scroll_offsets_->size();
for (size_t i = 0; i < size; ++i) {
if (!DataEquivalent(scroll_offsets_->at(i), other.at(i)))
return false;
}
return true;
}
ScrollTimeline::TimelineState ScrollTimeline::ComputeTimelineState() const {
// 1. If scroll timeline is inactive, return an unresolved time value.
// https://github.com/WICG/scroll-animations/issues/31
......@@ -380,7 +394,7 @@ void ScrollTimeline::SnapshotState() {
timeline_state_snapshotted_ = ComputeTimelineState();
}
Element* ScrollTimeline::scrollSource() {
Element* ScrollTimeline::scrollSource() const {
return scroll_source_.Get();
}
......
......@@ -60,7 +60,7 @@ class CORE_EXPORT ScrollTimeline : public AnimationTimeline {
void ScheduleNextService() override;
// IDL API implementation.
Element* scrollSource();
Element* scrollSource() const;
String orientation();
// TODO(crbug.com/1094014): scrollOffsets will replace start and end
// offsets once spec decision on multiple scroll offsets is finalized.
......@@ -115,6 +115,9 @@ class CORE_EXPORT ScrollTimeline : public AnimationTimeline {
protected:
PhaseAndTime CurrentPhaseAndTime() override;
double GetTimeRange() const { return time_range_; }
bool ScrollOffsetsEqual(
const HeapVector<Member<ScrollTimelineOffset>>& other) const;
private:
// https://wicg.github.io/scroll-animations/#avoiding-cycles
......
......@@ -34,6 +34,7 @@ const char kPlatformColorChange[] = "PlatformColorChange";
const char kPluginChanged[] = "Plugin Changed";
const char kPropertyRegistration[] = "PropertyRegistration";
const char kPseudoClass[] = "PseudoClass";
const char kScrollTimeline[] = "ScrollTimeline";
const char kSVGContainerSizeChange[] = "SVGContainerSizeChange";
const char kSettings[] = "Settings";
const char kShadow[] = "Shadow";
......
......@@ -35,6 +35,7 @@ extern const char kPlatformColorChange[];
extern const char kPluginChanged[];
extern const char kPropertyRegistration[];
extern const char kPseudoClass[];
extern const char kScrollTimeline[];
extern const char kSVGContainerSizeChange[];
extern const char kSettings[];
extern const char kShadow[];
......
......@@ -1676,6 +1676,9 @@ void StyleEngine::ApplyRuleSetChanges(
if (RuleSet* rule_set = active_sheet.second)
AddScrollTimelineRules(*rule_set);
}
MarkAllElementsForStyleRecalc(StyleChangeReasonForTracing::Create(
style_change_reason::kScrollTimeline));
}
}
......
<!DOCTYPE html>
<link rel="help" src="https://drafts.csswg.org/scroll-animations-1/#scroll-timeline-at-rule">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/web-animations/testcommon.js"></script>
<style>
#scrollers > div {
overflow: scroll;
width: 100px;
height: 100px;
}
#scrollers > div > div {
height: 200px;
}
@keyframes expand {
from { width: 100px; }
to { width: 200px; }
}
#element {
width: 0px;
height: 20px;
animation-name: expand;
animation-duration: 1e10s;
animation-timing-function: linear;
}
</style>
<div id=scrollers>
<div id=scroller1><div></div></div>
<div id=scroller2><div></div></div>
</div>
<div id=container></div>
<script>
// Force layout of scrollers.
scroller1.offsetTop;
scroller2.offsetTop;
scroller1.scrollTop = 20;
scroller2.scrollTop = 40;
function insertElement() {
let element = document.createElement('div');
element.id = 'element';
container.append(element);
return element;
}
function insertSheet(text) {
let style = document.createElement('style');
style.textContent = text;
container.append(style);
return style;
}
// Insert an @scroll-timeline rule given 'options', where each option
// has a reasonable default.
function insertScrollTimeline(options) {
if (typeof(options) == 'undefined')
options = {};
if (typeof(options.name) == 'undefined')
options.name = 'timeline';
if (typeof(options.source) == 'undefined')
options.source = 'selector(#scroller1)';
if (typeof(options.timeRange) == 'undefined')
options.timeRange = '1e10s';
if (typeof(options.start) == 'undefined')
options.start = '0px';
if (typeof(options.end) == 'undefined')
options.end = '100px';
return insertSheet(`
@scroll-timeline ${options.name} {
source: ${options.source};
time-range: ${options.timeRange};
start: ${options.start};
end: ${options.end};
}
`);
}
// Runs a test with dynamically added/removed elements or CSS rules.
// Each test is instantiated twice: once for the initial style resolve where
// the DOM change was effectuated, and once after scrolling.
function dynamic_rule_test(func, description) {
// assert_width is an async function which verifies that the computed value
// of 'width' is as expected.
const instantiate = (assert_width, description) => {
promise_test(async (t) => {
try {
await func(t, assert_width);
} finally {
while (container.firstChild)
container.firstChild.remove();
}
}, description);
};
// Verify that the computed style is as expected immediately after the
// rule change took place.
instantiate(async (element, expected) => {
assert_equals(getComputedStyle(element).width, expected);
}, description + ' [immediate]');
// Verify that the computed style after scrolling a bit.
instantiate(async (element, expected) => {
scroller1.scrollTop = scroller1.scrollTop + 1;
scroller2.scrollTop = scroller2.scrollTop + 1;
await waitForNextFrame();
scroller1.scrollTop = scroller1.scrollTop - 1;
scroller2.scrollTop = scroller2.scrollTop - 1;
await waitForNextFrame();
assert_equals(getComputedStyle(element).width, expected);
}, description + ' [scroll]');
}
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
// This element initially has a DocumentTimeline.
await assert_width(element, '100px');
// Switch to scroll timeline.
let sheet1 = insertScrollTimeline();
let sheet2 = insertSheet('#element { animation-timeline: timeline; }');
await assert_width(element, '120px');
// Switching from ScrollTimeline -> DocumentTimeline should preserve
// current time.
sheet1.remove();
sheet2.remove();
await assert_width(element, '120px');
}, 'Switching between document and scroll timelines');
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
// Note: #scroller1 is at 20%, and #scroller2 is at 40%.
insertScrollTimeline({name: 'timeline1', source: 'selector(#scroller1)'});
insertScrollTimeline({name: 'timeline2', source: 'selector(#scroller2)'});
insertSheet(`
.tl1 { animation-timeline: timeline1; }
.tl2 { animation-timeline: timeline2; }
`);
await assert_width(element, '100px');
element.classList.add('tl1');
await assert_width(element, '120px');
element.classList.add('tl2');
await assert_width(element, '140px');
element.classList.remove('tl2');
await assert_width(element, '120px');
// Switching from ScrollTimeline -> DocumentTimeline should preserve
// current time.
element.classList.remove('tl1');
await assert_width(element, '120px');
}, 'Changing computed value of animation-timeline changes effective timeline');
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
insertScrollTimeline({source: 'selector(#scroller1)'});
insertSheet(`
.scroll { animation-timeline: timeline; }
.none { animation-timeline: none; }
`);
// DocumentTimeline applies by default.
await assert_width(element, '100px');
// DocumentTimeline -> none
element.classList.add('none');
await assert_width(element, '0px');
// none -> DocumentTimeline
element.classList.remove('none');
await assert_width(element, '100px');
// DocumentTimeline -> ScrollTimeline
element.classList.add('scroll');
await assert_width(element, '120px');
// ScrollTimeline -> none
element.classList.add('none');
await assert_width(element, '0px');
// none -> ScrollTimeline
element.classList.remove('none');
await assert_width(element, '120px');
}, 'Changing to/from animation-timeline:none');
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
insertSheet('#element { animation-timeline: timeline; }');
await assert_width(element, '0px');
insertScrollTimeline({source: 'selector(#scroller1)'});
await assert_width(element, '120px');
insertScrollTimeline({source: 'selector(#scroller2)'});
await assert_width(element, '140px');
}, 'Changing the source descriptor switches effective timeline');
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
insertSheet('#element { animation-timeline: timeline; }');
await assert_width(element, '0px');
insertScrollTimeline({timeRange: '1e10s'});
await assert_width(element, '120px');
insertScrollTimeline({timeRange: '1e9s'});
await assert_width(element, '102px');
}, 'Changing the time-range descriptor switches effective timeline');
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
insertSheet('#element { animation-timeline: timeline; }');
await assert_width(element, '0px');
insertScrollTimeline({start: '0px'});
await assert_width(element, '120px');
insertScrollTimeline({start: '50px'});
await assert_width(element, '0px');
}, 'Changing the start descriptor switches effective timeline');
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
insertSheet('#element { animation-timeline: timeline; }');
await assert_width(element, '0px');
insertScrollTimeline({end: '100px'});
await assert_width(element, '120px');
insertScrollTimeline({end: '10px'});
await assert_width(element, '0px');
}, 'Changing the end descriptor switches effective timeline');
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
let reverse = insertSheet('#element { animation-direction: reverse; }');
insertSheet('#element { animation-timeline: timeline; }');
await assert_width(element, '0px');
// Note: #scroller1 is at 20%.
insertScrollTimeline({source: 'selector(#scroller1)'});
await assert_width(element, '180px');
// Note: #scroller1 is at 40%.
insertScrollTimeline({source: 'selector(#scroller2)'});
await assert_width(element, '160px');
reverse.remove();
await assert_width(element, '140px');
}, 'Reverse animation direction');
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
insertSheet('#element { animation-timeline: timeline; }');
await assert_width(element, '0px');
// Note: #scroller1 is at 20%.
insertScrollTimeline({source: 'selector(#scroller1)'});
await assert_width(element, '120px');
let paused = insertSheet('#element { animation-play-state: paused; }');
// We should still be at the same position after pausing.
await assert_width(element, '120px');
// Note: #scroller1 is at 40%.
insertScrollTimeline({source: 'selector(#scroller2)'});
// Even when switching timelines, we should be at the same position until
// we unpause.
await assert_width(element, '120px');
// Unpausing should synchronize to the scroll position.
paused.remove();
await assert_width(element, '140px');
}, 'Switching timelines while paused');
dynamic_rule_test(async (t, assert_width) => {
let element = insertElement();
// Note: #scroller1 is at 20%.
insertScrollTimeline({source: 'selector(#scroller1)'});
await assert_width(element, '100px');
insertSheet(`#element {
animation-timeline: timeline;
animation-play-state: paused;
}`);
// Pausing should happen before the timeline is modified. (Tentative).
// https://github.com/w3c/csswg-drafts/issues/5653
await assert_width(element, '100px');
insertSheet('#element { animation-play-state: running; }');
await assert_width(element, '120px');
}, 'Switching timelines and pausing at the same time');
</script>
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment