Skip to content
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

amp-position-observer and redesign of scrollbound/visibility-based behaviour #10818

Merged
merged 43 commits into from
Aug 15, 2017

Conversation

aghassemi
Copy link
Contributor

@aghassemi aghassemi commented Aug 7, 2017

Closes #8411 #9301 #10880

New Approach to scrollbound and visibility-based behaviour

  • Allow any two components with a "timeline" concept to sync.
  • Syncing is done through AMP actions using LOW ActionTrust to prevent abuse
  • This architecture allows for syncing various components:
    • amp-animation <> amp-position-observer (scrollbound animations and visibility-based animation scenes)
    • amp-animation <> amp-video-player-x (Not implemented in this PR, syncing animation and videos)
    • amp-video-player-x <> amp-position-observer(Not implemented in this PR, scrollbound behaviour for videos)
    • and potentially any two components that can trigger 3 enter exit progress events (e.g. range input, carousel, XHR call, etc..)

amp-position-observer

  • Monitors visibility of its parent (or another element)
  • Triggers enter , exit, scroll(percent) LOW-TRUST events. (i.e. has "timeline")
  • Allows for custom visibility configurations through use of ratios and margins (similar to IntersectionObserver)

Examples

Play my animation scene when 50% visible and pause when 50% invisible

<amp-animation id="anim">...</amp-animation>
<div class="scene">
  <amp-position-observer
     intersection-ratio="0.5"
     on="enter:anim.start;exit:anim.pause"
   ></amp-position-observer>
 ...
<amp-img>...</amp-img>
 ...
</div>

visibility demo

Play my animation using scrolling instead of time.

Animation starts when scene is fully visible and pauses as soon as partially invisible.

<amp-animation id="anim">...</amp-animation>
<div class="scene">
  <amp-position-observer
    intersection-ratio="1"
    on="scroll:anim.seekTo(percent=event.percent)"
   ></amp-position-observer>
...
<amp-img>...</amp-img>
 ...
</div>

Scrollbound animation demo

Play a video when a heading is scrolled into view.

Imagine a position fixed video that would play automatically when user scrolls to a related heading somewhere in the page.

 <amp-youtube id="video" class="fixed"></amp-youtube>
 <amp-position-observer
    targetSelector="#heading3"
    intersection-ratio="1"
    viewport-margins="25vh" // only when heading comes into view in the middle 50% of viewport
    on="enter:video.play;exit:video.pause"
    ></amp-position-observer>

<h2 id="heading1">...</h2>
<h2 id="heading2">...</h2>
<h2 id="heading3">...</h2>

Sync my animation timeline with this video (Not implemented in this PR)

Animation starts when video starts, pauses when video is paused, seeking the video also seeks the animation, animation ends when video ends.

<amp-animation id="anim">...</amp-animation>
<amp-video
  on="play:anim.start; pause:anim.pause; progress:anim.seekTo(percent=event.percent)"
</amp-video>

Play my video using scrolling instead of time (Not implemented in this PR)

Similar to scrollbound animation, but with a video instead

<div class="scene">
  <amp-position-observer
    intersection-ratio="1"
    on="scroll:video.seekTo(percent=event.percent)"
   ></amp-position-observer>

<amp-video id="video"></amp-video>
 ...
</div>

@aghassemi aghassemi changed the title amp-visibility-observer and redesign of scrollbound/visibility-based behaviour amp-position-observer and redesign of scrollbound/visibility-based behaviour Aug 10, 2017
@aghassemi
Copy link
Contributor Author

/to @dvoytenko This is ready for review. (thanks!). It should be a fairly complete PR with docs, integration and functional tests.
/cc @ericlindley-g When PR is merged, we can consider this feature in experimental and advertise to get feedback (documentation and examples should be enough for anyone to get started)
/cc @cramforce For format (note the name change since review meeting)
/cc @zhouyx We could bring the margin and ratio logic to core position observer if any needs comes up.

Remaining items:

Copy link

@ghost ghost left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve of .md file changes

Copy link
Contributor

@honeybadgerdontcare honeybadgerdontcare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when none of the attributes are used on this custom element?

# limitations under the license.
#

tags: { # amp-position-observer script
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two spaces between { and # and omit word script.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all done, if nothing is specified, defaults kick in (0 and 0 for both margin and ratios)

attrs: {
name: "intersection-ratios"
# "0", "0.1" , "1", "0 1", "0 0.5", "0.5 1", "1 1", etc...
value_regex: "^([0]*?\.\d*$|1$|0$)|([0]*?\.\d*|1|0)\s{1}([0]*?\.\d*$|1$|0$)"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that value_regex needs \ to be escaped to work properly. For instance \d should be \\d. To handle this, we usually include the unescaped version above in a comment. For example see amp-timeago.

value_regex: "^([0]*?\.\d*$|1$|0$)|([0]*?\.\d*|1|0)\s{1}([0]*?\.\d*$|1$|0$)"
}
attrs: {
name: "exclusion-margins"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please alphabetize the attributes. E.g. this would go above intersection-ratios.

attrs: {
name: "exclusion-margins"
# "100", "100px", "100vh", "100 100px", "100vh 100", "100px 100vh", etc..
value_regex: "^(\d+$|\d+px$|\d+vh$)|((\d+|\d+px|\d+vh)\s{1}(\d+$|\d+px$|\d+vh$))"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, needs escaping and comment with unescaped.

<amp-position-observer exclusion-margins="100vh 200px" layout="nodisplay"></amp-position-observer>
<amp-position-observer exclusion-margins="100 200vh" layout="nodisplay"></amp-position-observer>

<!-- Invalid -->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually group invalid ones with comments stating why they're invalid.

E.g. <!-- Invalid: missing required layout value -->, <!-- Invalid: interesection-ratios not valid entries -->, etc.

Copy link
Contributor

@honeybadgerdontcare honeybadgerdontcare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding this since I forgot to check request changes.

}
this.maybeCreateRunner_().then(() => {
if (this.visible_ && this.triggered_) {
this.runner_.resume();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reads strange. maybeCreateRunner_ reads as "runner may end up created and maybe not", but inside the runner_ is used w/o check. Is this because promise won't be resolved or will be rejected if runner is not created?

}
// percent based seek
const percent = parseFloat(invocation.args && invocation.args['percent']);
if (isFiniteNumber(percent) && percent <= 1 && percent >= 0) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A better way, i think, would be to just do minmax[percent, 0, 1]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added clamp to math.js

@@ -262,7 +289,7 @@ export class AmpAnimation extends AMP.BaseElement {
setVisible_(visible) {
if (this.visible_ != visible) {
this.visible_ = visible;
if (this.triggered_) {
if (this.triggered_ && this.triggerOnVisibility_) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. Why would it be triggered then? Would it be better to reset triggered_ to false instead?

* @private
*/
maybeCreateRunner_(opt_args) {
if (!this.runnerPromise_ || !this.runner_) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how to read this: why would one exist but the other would not? Further, if runner_ exists but promise doesn't, why do we recreate the runner? Shouldn't the previous runner be destroyed/stopped/etc? It will continue playing otherwise. This is from reading the code, so maybe just some asserts missing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All done as discussed. Re-did the actions integrations tests so they mock less

* @return {!Promise}
* @private
*/
maybeCreateRunner_(opt_args) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are potential consequences of this? Is it strictly necessary? Is it to be able to seek right away? Maybe it's better to animate() right before the first seek? Feels like a biggish change from before, but maybe it's not.

}
}

this.targetSelector_ = this.element.getAttribute('target-selector');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I think the target="id" form is better for this use case. Selector kind of implies behavior for multiple targets. This also clarifies typical confusion around the direction of DOM search, e.g. it's not a closest thing, etc. Asking folk to add an ID for this use case is very reasonable. Especially since it's optional anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

the target is entering/exiting from top (0 will be used) or bottom (1 will be used).


#### exclusion-margins (optional)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit bikeshed on name. Would it be better to rename to root margins or viewport margins to get it closer to InOb terminology which we use elsewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

viewport-margins it is

const totalDuration = adjustedViewportRect.height +
positionRect.height - totalDurationOffset;

const topOffset = Math.abs(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't the topRatio used in this formula?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formula aligns everything to create an equivalent case where top ratio is 1.

* @private
*/
discoverScene_() {
if (this.targetSelector_) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above about target=id. But if selector is used and specified by the doc - it could be a bad ID and needs to be defined as such via try{}catch(){user.error("bad selector")}.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NA now

discoverScene_() {
if (this.targetSelector_) {
this.scene_ = user().assertElement(
this.getAmpDoc().getRootNode().querySelector(this.targetSelector_),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up question: is this code guaranteed to run after the DOM has been fully parsed? Typically we wrap query selectors into ampdoc.whenReady().then(...)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added whenReady

@aghassemi
Copy link
Contributor Author

@dvoytenko all done, PTAL

Copy link
Contributor

@honeybadgerdontcare honeybadgerdontcare left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validator changes look good

Copy link
Contributor

@dvoytenko dvoytenko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with a nit or two.

* @param {number} max1 the upper bound of the source range
* @param {number} min2 the lower bound of the target range
* @param {number} max2 the upper bound of the target range
* @param {!number} val the value in the source range
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: numbers and other primitives are non-nullable by default, so no need for !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

scroll-bound amp-animation
4 participants