diff --git a/build-system/dep-check-config.js b/build-system/dep-check-config.js index 725c835d2c77..188ed725750a 100644 --- a/build-system/dep-check-config.js +++ b/build-system/dep-check-config.js @@ -157,6 +157,8 @@ exports.rules = [ 'extensions/amp-youtube/0.1/amp-youtube.js->' + 'src/service/video-manager-impl.js', 'extensions/amp-a4a/0.1/amp-a4a.js->src/service/variable-source.js', + 'extensions/amp-fx-parallax/0.1/amp-fx-parallax.js->' + + 'src/service/parallax-impl.js', ], }, { diff --git a/examples/article-parallax.amp.html b/examples/article-parallax.amp.html new file mode 100644 index 000000000000..c41b03111bd7 --- /dev/null +++ b/examples/article-parallax.amp.html @@ -0,0 +1,85 @@ + + + + + AMP Article with parallax title + + + + + + + + +
+

+
+ Lorem Ipsum Dolor Sit Amet Consectetur Adipiscing +
+

+ +
+ +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus + luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse + potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin + felis aliquam tortor vulputate, ac posuere velit semper. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus + luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse + potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin + felis aliquam tortor vulputate, ac posuere velit semper. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus + luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse + potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin + felis aliquam tortor vulputate, ac posuere velit semper. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus + luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse + potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin + felis aliquam tortor vulputate, ac posuere velit semper. +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur ullamcorper turpis vel commodo scelerisque. Phasellus + luctus nunc ut elit cursus, et imperdiet diam vehicula. Duis et nisi sed urna blandit bibendum et sit amet erat. Suspendisse + potenti. Curabitur consequat volutpat arcu nec elementum. Etiam a turpis ac libero varius condimentum. Maecenas sollicitudin + felis aliquam tortor vulputate, ac posuere velit semper. +

+
+ + diff --git a/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js b/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js new file mode 100644 index 000000000000..1da4533973f3 --- /dev/null +++ b/extensions/amp-fx-parallax/0.1/amp-fx-parallax.js @@ -0,0 +1,24 @@ +/** + * Copyright 2017 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ampdocServiceFor} from '../../../src/ampdoc'; +import {installParallaxForDoc} from '../../../src/service/parallax-impl'; +import {onDocumentReady} from '../../../src/document-ready'; + +const ampdoc = ampdocServiceFor(AMP.win).getAmpDoc(); +onDocumentReady(ampdoc.win.document, () => { + installParallaxForDoc(ampdoc.getRootNode()); +}); diff --git a/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js b/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js new file mode 100644 index 000000000000..74fe98d39a8b --- /dev/null +++ b/extensions/amp-fx-parallax/0.1/test/test-amp-fx-parallax.js @@ -0,0 +1,202 @@ +/** + * Copyright 2017 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {createIframePromise} from '../../../../testing/iframe'; +import {installParallaxForDoc} from '../../../../src/service/parallax-impl'; +import {parallaxForDoc} from '../../../../src/parallax'; +import {toggleExperiment} from '../../../../src/experiments'; +import {viewportForDoc} from '../../../../src/viewport'; +import {vsyncFor} from '../../../../src/vsync'; + +describes.sandboxed('amp-fx-parallax', {}, () => { + const DEFAULT_FACTOR = 1.7; + + function addTextChildren(iframe) { + return [iframe.doc.createTextNode('AMP: Accelerated Mobile Pages')]; + } + + function getAmpParallaxElement(opt_childrenCallback, opt_factor, opt_top) { + const factor = opt_factor || DEFAULT_FACTOR; + const top = opt_top || 0; + let viewport; + let parallaxElement; + + return createIframePromise().then(iframe => { + const bodyResizer = iframe.doc.createElement('div'); + bodyResizer.style.height = '4000px'; + bodyResizer.style.width = '1px'; + iframe.doc.body.appendChild(bodyResizer); + + viewport = viewportForDoc(iframe.win.document); + viewport.resize_(); + + toggleExperiment(iframe.win, 'amp-fx-parallax', true); + + parallaxElement = iframe.doc.createElement('div'); + parallaxElement.setAttribute('amp-fx-parallax', factor); + if (opt_childrenCallback) { + const children = opt_childrenCallback(iframe, parallaxElement); + children.forEach(child => { + parallaxElement.appendChild(child); + }); + } + + const parent = iframe.doc.querySelector('#parent'); + parent.appendChild(parallaxElement); + installParallaxForDoc(iframe.doc); + + return new Promise(resolve => { + vsyncFor(iframe.win).mutate(() => { + resolve({ + element: parallaxElement, + iframe, + viewport, + }); + }); + viewport.setScrollTop(top); + }); + }).catch(error => { + return Promise.reject({error, parallaxElement, stack: error.stack}); + }); + } + + it('should move when the user scrolls, if visible', () => { + const scroll = 10; + const expectedParallax = -1 * DEFAULT_FACTOR * scroll; + + return getAmpParallaxElement(addTextChildren) + .then(({element, iframe, viewport}) => { + const parallaxService = parallaxForDoc(iframe.doc); + const top = element.getBoundingClientRect().top; + expect(top).to.equal(viewport.getScrollTop()); + + return new Promise(resolve => { + parallaxService.addScrollListener_(() => { + const top = element.getBoundingClientRect().top; + expect(top).to.equal(expectedParallax); + resolve(); + }); + viewport.setScrollTop(scroll); + }); + }); + }); + + it('should not move after it is outside of the viewport', () => { + const scroll = 100; + const expectedParallax = -1 * DEFAULT_FACTOR * scroll; + + return getAmpParallaxElement(addTextChildren, DEFAULT_FACTOR) + .then(({element, iframe, viewport}) => { + const parallaxService = parallaxForDoc(iframe.doc); + + return new Promise(resolve => { + parallaxService.addScrollListener_(() => { + const top = element.getBoundingClientRect().top; + expect(top).to.not.equal(expectedParallax); + resolve(); + }); + viewport.setScrollTop(scroll); + }); + }); + }); + + it('should move downward with a negative parallax factor', () => { + const scroll = 10; + const expectedParallax = -1 * DEFAULT_FACTOR * scroll; + + return getAmpParallaxElement(addTextChildren, DEFAULT_FACTOR) + .then(({element, iframe, viewport}) => { + const parallaxService = parallaxForDoc(iframe.doc); + return new Promise(resolve => { + parallaxService.addScrollListener_(() => { + const top = element.getBoundingClientRect().top; + expect(top).to.equal(expectedParallax); + resolve(); + }); + viewport.setScrollTop(scroll); + }); + }); + }); + + it('should apply multiple scrolls as if they were one large scroll', () => { + const scroll = 10; + const factor = -1.7; // move downward so it stays in the viewport + const expectedParallax = -1 * factor * 2 * scroll; + + return getAmpParallaxElement(addTextChildren, factor) + .then(({element, iframe, viewport}) => { + const parallaxService = parallaxForDoc(iframe.doc); + return new Promise(resolve => { + parallaxService.addScrollListener_(afterFirstScroll); + viewport.setScrollTop(scroll); + + function afterFirstScroll() { + parallaxService.removeScrollListener_(afterFirstScroll); + parallaxService.addScrollListener_(afterSecondScroll); + viewport.setScrollTop(2 * scroll); + } + + function afterSecondScroll() { + const top = element.getBoundingClientRect().top; + expect(top).to.equal(expectedParallax); + resolve(); + } + }); + }); + }); + + it('should return to its original position when scrolling back', () => { + const factor = -1.7; // move downward so it stays in the viewport + + return getAmpParallaxElement(addTextChildren, factor) + .then(({element, iframe, viewport}) => { + const parallaxService = parallaxForDoc(iframe.doc); + return new Promise(resolve => { + parallaxService.addScrollListener_(afterFirstScroll); + viewport.setScrollTop(10); + + function afterFirstScroll() { + parallaxService.removeScrollListener_(afterFirstScroll); + parallaxService.addScrollListener_(afterSecondScroll); + viewport.setScrollTop(200); + } + + function afterSecondScroll() { + parallaxService.removeScrollListener_(afterSecondScroll); + parallaxService.addScrollListener_(afterThirdScroll); + viewport.setScrollTop(0); + } + + function afterThirdScroll() { + const top = element.getBoundingClientRect().top; + expect(top).to.equal(0); + resolve(); + } + }); + }); + }); + + it('should render moved if the page loads partially scrolled', () => { + const scroll = 10; + const expectedParallax = -1 * DEFAULT_FACTOR * scroll; + + return getAmpParallaxElement(addTextChildren, DEFAULT_FACTOR, scroll) + .then(({element}) => { + const top = element.getBoundingClientRect().top; + expect(top).to.equal(expectedParallax); + }); + }); +}); diff --git a/extensions/amp-fx-parallax/amp-fx-parallax.md b/extensions/amp-fx-parallax/amp-fx-parallax.md new file mode 100644 index 000000000000..690e42766df7 --- /dev/null +++ b/extensions/amp-fx-parallax/amp-fx-parallax.md @@ -0,0 +1,53 @@ + + +# `amp-fx-parallax` + + + + + + + + + + + + + + + + + + +
Descriptionamp-fx-parallax enables a 3D-perspective effect on elements with the attribute.
AvailabilityIn development
Required Script<script async custom-element="amp-fx-parallax" src="https://cdn.ampproject.org/v0/amp-fx-parallax-0.1.js"></script>
ExamplesIn development
+ +## Behavior + +The `amp-fx-parallax` attribute causes an element to move as if it is nearer or farther relative to the foreground of the page content. As the user scrolls the page, the element scrolls faster or slower depending on the value assigned to the attribute. + +Example: + +```html + + +``` + +## Attributes + +**amp-fx-parallax** + +The factor to use when scrolling. A value greater than 1 scrolls the element upward when the user scrolls down the page. A value less than 1 scrolls the element downward when the user scrolls downward. A value of 1 behaves normally. A value of 0 effectively makes the element scroll fixed with the page. diff --git a/gulpfile.js b/gulpfile.js index 4d8550eeb112..fbafd38e78d0 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -78,6 +78,7 @@ declareExtension('amp-font', '0.1', false, 'NO_TYPE_CHECK'); declareExtension('amp-form', '0.1', true); declareExtension('amp-fresh', '0.1', true); declareExtension('amp-fx-flying-carpet', '0.1', true); +declareExtension('amp-fx-parallax', '0.1', false); declareExtension('amp-gfycat', '0.1', false); declareExtension('amp-hulu', '0.1', false); declareExtension('amp-iframe', '0.1', false, 'NO_TYPE_CHECK'); diff --git a/src/parallax.js b/src/parallax.js new file mode 100644 index 000000000000..4d8a33d5d816 --- /dev/null +++ b/src/parallax.js @@ -0,0 +1,27 @@ +/** + * Copyright 2017 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {getExistingServiceForDoc} from './service'; + + +/** + * @param {!Node|!./service/ampdoc-impl.AmpDoc} nodeOrDoc + * @return {!./service/parallax-impl.ParallaxService} + */ +export function parallaxForDoc(nodeOrDoc) { + return /** @type {!./service/parallax-impl.ParallaxService} */ ( + getExistingServiceForDoc(nodeOrDoc, 'amp-fx-parallax')); +}; diff --git a/src/service/parallax-impl.js b/src/service/parallax-impl.js new file mode 100644 index 000000000000..b765ed36a07d --- /dev/null +++ b/src/service/parallax-impl.js @@ -0,0 +1,215 @@ +/** + * Copyright 2017 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Observable} from '../observable'; +import {fromClassForDoc} from '../service'; +import {isExperimentOn} from '../experiments'; +import {setStyles} from '../style'; +import {toArray} from '../types'; +import {user} from '../log'; +import {viewportForDoc} from '../viewport'; +import {vsyncFor} from '../vsync'; + +const ATTR = 'amp-fx-parallax'; +const EXPERIMENT = ATTR; + +/** + * Installs parallax handlers, tracks the previous scroll position and + * implements post-parallax-update scroll hooks. + */ +export class ParallaxService { + /** + * @param {!./ampdoc-impl.AmpDoc} ampdoc + */ + constructor(ampdoc) { + /** @private @const {!Observable} */ + this.parallaxObservable_ = new Observable(); + + /** @private {number} */ + this.previousScroll_ = 0; + + this.installParallaxHandlers_(ampdoc.win); + } + + + /** + * Constructs and installs scroll handlers on all [amp-fx-parallax] elements + * in the document. + * @param {!Window} global + * @private + */ + installParallaxHandlers_(global) { + const doc = global.document; + const viewport = viewportForDoc(doc); + const vsync = vsyncFor(global); + + const elements = toArray(doc.querySelectorAll(`[${ATTR}]`)); + const parallaxElements = elements.map( + e => new ParallaxElement(e, this.transform_)); + const mutate = + this.parallaxMutate_.bind(this, parallaxElements, viewport); + + viewport.onScroll(() => vsync.mutate(mutate)); + mutate(); // initialize the elements with the current scroll position + } + + /** + * Update each [amp-fx-parallax] element with the new scroll position. + * Notify any listeners. + * @param {!Array} elements + * @param {!./viewport-impl.Viewport} viewport + * @private + */ + parallaxMutate_(elements, viewport) { + const newScrollTop = viewport.getScrollTop(); + const previousScrollTop = this.getPreviousScroll_(); + const delta = previousScrollTop - newScrollTop; + + elements.forEach(element => { + if (!element.shouldUpdate(viewport)) { + return; + } + element.update(delta); + this.setPreviousScroll_(newScrollTop); + }); + + this.fire_(newScrollTop); + } + + /** + * Create a value for the CSS transform property given a position. + * @param {number} position + * @return {string} + */ + transform_(position) { + return `translate3d(0,${position.toFixed(2)}px,0)`; + } + + /** + * Get the previous scroll value. + * @return {number} + * @private + */ + getPreviousScroll_() { + return this.previousScroll_; + } + + /** + * Set the previous scroll value. + * @param {number} scroll + * @private + */ + setPreviousScroll_(scroll) { + this.previousScroll_ = scroll; + } + + /** + * Add listeners to parallax scroll events. + * @param {!function()} cb + * @private + * @visibleForTesting + */ + addScrollListener_(cb) { + this.parallaxObservable_.add(cb); + } + + /** + * Remove listeners from parallax scroll events. + * @param {!function()} cb + * @private + * @visibleForTesting + */ + removeScrollListener_(cb) { + this.parallaxObservable_.remove(cb); + } + + /** + * Alert listeners that a scroll has occurred. + * @param {number} scrollTop + * @private + */ + fire_(scrollTop) { + this.parallaxObservable_.fire(scrollTop); + } +} + +/** + * Encapsulates and tracks an element's linear parallax effect. + */ +export class ParallaxElement { + /** + * @param {!Element} element The element to give a parallax effect. + * @param {!function(number):string} transform Computes the transform from the position. + */ + constructor(element, transform) { + const factor = element.getAttribute(ATTR); + + /** @private @const {!Element} */ + this.element_ = element; + + /** @private @const {!function(number):string} */ + this.transform_ = transform; + + /** @private @const {number} */ + this.factor_ = (factor ? parseFloat(factor) : 0.5) - 1; + + /** @private {number} */ + this.offset_ = 0; + } + + /** + * Apply the parallax effect to the offset given how much the page + * has moved since the last frame. + * @param {number} delta The movement of the base layer e.g. the page. + */ + update(delta) { + this.offset_ += delta * this.factor_; + setStyles(this.element_, {transform: this.transform_(this.offset_)}); + } + + /** + * True if the element is in the viewport. + * @param {!./viewport-impl.Viewport} viewport + * @return {boolean} + */ + shouldUpdate(viewport) { + const viewportRect = viewport.getRect(); + const elementRect = viewport.getLayoutRect(this.element_); + elementRect.top -= viewportRect.top; + elementRect.bottom = elementRect.top + elementRect.height; + return this.isRectInView_(elementRect, viewportRect.height); + } + + /** + * Check if a rectange is within the viewport. + * @param {!../layout-rect.LayoutRectDef} rect + * @param {number} viewportHeight + * @private + */ + isRectInView_(rect, viewportHeight) { + return rect.bottom >= 0 && rect.top <= viewportHeight; + } +} + +/** + * @param {!Node|!./ampdoc-impl.AmpDoc} nodeOrDoc + * @return {!ParallaxService} + */ +export function installParallaxForDoc(nodeOrDoc) { + const enabled = isExperimentOn(global, EXPERIMENT); + user().assert(enabled, `Experiment "${EXPERIMENT}" is disabled.`); + return fromClassForDoc(nodeOrDoc, 'amp-fx-parallax', ParallaxService); +}; diff --git a/tools/experiments/experiments.js b/tools/experiments/experiments.js index 35eabb507612..7b8108dc4d99 100644 --- a/tools/experiments/experiments.js +++ b/tools/experiments/experiments.js @@ -261,6 +261,12 @@ const EXPERIMENTS = [ 'Only apply to 1.0 version', cleanupIssue: 'https://github.com/ampproject/amphtml/issues/7479', }, + { + id: 'amp-fx-parallax', + name: 'Amp extension for a parallax effect', + cleanupIssue: 'https://github.com/ampproject/amphtml/issues/7801', + spec: 'https://github.com/ampproject/amphtml/issues/1443', + }, ]; if (getMode().localDev) {