Skip to content

Instantly share code, notes, and snippets.

@NullVoxPopuli
Created November 21, 2018 18:56
Show Gist options
  • Save NullVoxPopuli/3e931d15599f8609b5f2d52f97cbd56c to your computer and use it in GitHub Desktop.
Save NullVoxPopuli/3e931d15599f8609b5f2d52f97cbd56c to your computer and use it in GitHub Desktop.
exports.default = Ember.Component.extend({
layout: _animatedEach.default,
tagName: '',
motionService: Ember.inject.service('-ea-motion'),
/**
* The list of data you are trying to render.
@argument items
@type Array
*/
items: null,
/**
* If set, this animator will only [match](../../between) other animators that have the same group value.
@argument group
@type String
*/
group: null,
/**
* Represents the amount of time an animation takes in miliseconds.
@argument duration
@type Number
*/
duration: null,
/**
* Specifies the [Transition](../../transitions)
* to run when the list changes.
@argument use
@type Transition
*/
use: null,
/**
* Specifies data-dependent [Rules](../../rules) that choose which [Transition](../../transitions)
* to run when the list changes. This takes precedence over `use`.
@argument rules
@type Rules
*/
rules: null,
/**
* When true, all the items in the list will animate as [`insertedSprites`](../../sprites) when the `{{#animated-each}}` is first rendered. Defaults to false.
@argument initialInsertion
@type Boolean
*/
initialInsertion: false,
/**
* When true, all the items in the list will animate as [`removedSprites`](../../sprites) when the `{{#animated-each}}` is destroyed. Defaults to false.
@argument finalRemoval
@type Boolean
*/
finalRemoval: false,
/**
Serves the same purpose as the `key` in ember `{{#each}}`, and it's
also used to compare values when [animating between components](../../between).
@argument key
@type String
*/
key: null,
init() {
this._elementToChild = new WeakMap();
this._prevItems = [];
this._prevSignature = [];
this._firstTime = true;
this._inserted = false;
this._renderedChildren = [];
this._cycleCounter = 0;
this._keptSprites = null;
this._insertedSprites = null;
this._removedSprites = null;
this.maybeReanimate = this.maybeReanimate.bind(this);
this.ancestorIsAnimating = this.ancestorIsAnimating.bind(this);
this.get('motionService').register(this).observeDescendantAnimations(this, this.maybeReanimate).observeAncestorAnimations(this, this.ancestorIsAnimating);
this._installObservers();
this._lastTransition = null;
this._ancestorWillDestroyUs = false;
this._super();
},
_installObservers() {
let key = this.get('key');
if (key != null && key !== '@index' && key !== '@identity') {
this.addObserver(`items.@each.${key}`, this, this._invalidateRenderedChildren);
}
let deps = this.get('_deps');
if (deps) {
for (let dep of deps) {
this.addObserver(`items.@each.${dep}`, this, this._invalidateRenderedChildren);
}
}
},
_deps: Ember.computed('watch', function () {
let w = this.get('watch');
// Firefox has an `Object.prototype.watch` that can troll us here
if (typeof w === 'string') {
return w.split(/\s*,\s*/);
}
}),
durationWithDefault: Ember.computed('duration', function () {
let d = this.get('duration');
if (d == null) {
return 2000;
} else {
return d;
}
}),
_invalidateRenderedChildren() {
this.notifyPropertyChange('renderedChildren');
},
_identitySignature(items, getKey) {
if (!items) {
return [];
}
let deps = this.get('_deps');
let signature = [];
for (let i = 0; i < items.length; i++) {
let item = items[i];
signature.push(getKey(item));
if (deps) {
for (let j = 0; j < deps.length; j++) {
let dep = deps[j];
signature.push(Ember.get(item, dep));
}
}
}
return signature;
},
// this is where we handle most of the model state management. Based
// on the `items` array we were given and our own earlier state, we
// update a list of Child models that will be rendered by our
// template and decide whether an animation is needed.
renderedChildren: Ember.computed('items.[]', 'group', function () {
let firstTime = this._firstTime;
this._firstTime = false;
let getKey = this.get('keyGetter');
let oldChildren = this._renderedChildren;
let oldItems = this._prevItems;
let oldSignature = this._prevSignature;
let newItems = this.get('items');
let newSignature = this._identitySignature(newItems, getKey);
let group = this.get('group') || '__default__';
this._prevItems = newItems ? newItems.slice() : [];
this._prevSignature = newSignature;
if (!newItems) {
newItems = [];
}
let oldIndices = new Map();
oldChildren.forEach((child, index) => {
oldIndices.set(child.id, index);
});
let newIndices = new Map();
newItems.forEach((item, index) => {
newIndices.set(getKey(item), index);
});
let newChildren = newItems.map((value, listIndex) => {
let id = getKey(value);
let index = oldIndices.get(id);
if (index != null) {
let child = new Child(group, id, value, listIndex);
child.state = 'kept';
return child;
} else {
return new Child(group, id, value, listIndex);
}
}).concat(oldChildren.filter(child => !child.shouldRemove && newIndices.get(child.id) == null).map(child => {
child.flagForRemoval();
return child;
}));
this._renderedChildren = newChildren;
if (typeof FastBoot === 'undefined' && !isStable(oldSignature, newSignature)) {
let transition = this._transitionFor(firstTime, oldItems, newItems);
this.get('animate').perform(transition, firstTime);
}
return newChildren;
}),
isAnimating: Ember.computed.alias('animate.isRunning'),
keyGetter: Ember.computed('key', function () {
return (0, _emberInternals.keyForArray)(this.get('key'));
}),
didInsertElement() {
this._inserted = true;
},
*_ownElements() {
if (!this._inserted) {
return;
}
var _componentNodes = (0, _emberInternals.componentNodes)(this);
let firstNode = _componentNodes.firstNode,
lastNode = _componentNodes.lastNode;
let node = firstNode;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE) {
yield node;
}
if (node === lastNode) {
break;
}
node = node.nextSibling;
}
},
// This gets called by the motionService when another animator calls
// willAnimate from within our descendant components.
maybeReanimate() {
if (this.get('animate.isRunning') && !this.get('startAnimation.isRunning')) {
// A new animation is starting below us while we are in
// progress. We should interrupt ourself in order to adapt to
// the changing conditions.
this.get('animate').perform(this._lastTransition);
}
},
ancestorIsAnimating(ourState) {
if (ourState === 'removing' && !this._ancestorWillDestroyUs) {
// we just found out we're probably getting destroyed. Abandon
// ship!
this._ancestorWillDestroyUs = true;
this._letSpritesEscape();
} else if (ourState !== 'removing' && this._ancestorWillDestroyUs) {
// we got a reprieve, our destruction was cancelled before it
// could happen.
this._ancestorWillDestroyUs = false;
// treat all our sprites as re-inserted, because we had already handed them off as orphans
let transition = this._transitionFor(this._firstTime, [], this._prevItems);
this.get('animate').perform(transition);
}
},
_letSpritesEscape() {
let transition = this._transitionFor(this._firstTime, this._prevItems, []);
let removedSprites = [];
let parent;
for (let element of this._ownElements()) {
if (!parent) {
parent = _sprite.default.offsetParentStartingAt(element);
}
let sprite = _sprite.default.positionedStartingAt(element, parent);
sprite.owner = this._elementToChild.get(element);
removedSprites.push(sprite);
}
this.get('motionService').matchDestroyed(removedSprites, transition, this.get('durationWithDefault'), this.get('finalRemoval'));
},
willDestroyElement() {
// if we already got early warning, we already let our sprites escape.
if (!this._ancestorWillDestroyUs) {
this._letSpritesEscape();
}
this.get('motionService').unregister(this).unobserveDescendantAnimations(this, this.maybeReanimate).unobserveAncestorAnimations(this, this.ancestorIsAnimating);
},
// this gets called by motionService when we call
// staticMeasurement. But also whenever *any other* animator calls
// staticMeasurement, even if we're in the middle of animating.
beginStaticMeasurement() {
if (this._keptSprites) {
this._keptSprites.forEach(sprite => sprite.unlock());
this._insertedSprites.forEach(sprite => sprite.unlock());
this._removedSprites.forEach(sprite => sprite.display(false));
}
},
endStaticMeasurement() {
if (this._keptSprites) {
this._keptSprites.forEach(sprite => sprite.lock());
this._insertedSprites.forEach(sprite => sprite.lock());
this._removedSprites.forEach(sprite => sprite.display(true));
}
},
_findCurrentSprites() {
let currentSprites = [];
let parent;
for (let element of this._ownElements()) {
if (!parent) {
parent = _sprite.default.offsetParentStartingAt(element);
}
let sprite = _sprite.default.positionedStartingAt(element, parent);
currentSprites.push(sprite);
}
return { currentSprites, parent };
},
_partitionKeptAndRemovedSprites(currentSprites) {
currentSprites.forEach(sprite => {
if (!sprite.element.parentElement) {
// our currentSprites list was created based on what was in
// DOM before rendering. Now we are looking after
// rendering. So some of the removed sprites may have been
// garbage collected out (based on the logic in
// renderedChildren()). If so, they will no longer be in the
// DOM, and we filter them out here.
return;
}
let child = this._elementToChild.get(sprite.element);
sprite.owner = child;
if (this._ancestorWillDestroyUs) {
this._removedSprites.push(sprite);
} else {
switch (child.state) {
case 'kept':
this._keptSprites.push(sprite);
break;
case 'removing':
this._removedSprites.push(sprite);
break;
case 'new':
// This can happen when our animation gets restarted due to
// another animation possibly messing with our DOM, as opposed
// to restarting because our own data changed.
this._keptSprites.push(sprite);
break;
default:
(true && Ember.warn(`Probable bug in ember-animated: saw unexpected child state ${child.state}`, false, { id: "ember-animated-state" }));
}
}
});
},
// The animate task is split into three subtasks that represent
// three distinct phases. This is necessary for the proper
// coordination of multiple animators.
//
// 1. During `startAnimation`, we ignore notifications about
// descendant animations (see maybeReanimate), because we're still
// waiting for Ember to finish rendering anyway and we haven't
// kicked off our own animation.
//
// 2. During `runAnimation`, other animators will know that we are
// actually still animating things, so if they are entangled with
// us they should not finalize. (They get entangled via farMatch,
// meaning some of their sprites and some of our sprites match
// up).
//
// 3. During `finalizeAnimation`, we are waiting for our entangled
// animators that are still in `runAnimation`, then we are
// cleaning up our own sprite state.
//
animate: (0, _emberScheduler.task)(function* (transition, firstTime) {
var _ref = yield this.get('startAnimation').perform(transition, (0, _scheduler.current)());
let parent = _ref.parent,
currentSprites = _ref.currentSprites,
insertedSprites = _ref.insertedSprites,
keptSprites = _ref.keptSprites,
removedSprites = _ref.removedSprites;
var _ref2 = yield this.get('runAnimation').perform(transition, parent, currentSprites, insertedSprites, keptSprites, removedSprites, firstTime);
let matchingAnimatorsFinished = _ref2.matchingAnimatorsFinished;
yield this.get('finalizeAnimation').perform(insertedSprites, keptSprites, removedSprites, matchingAnimatorsFinished);
}).restartable(),
startAnimation: (0, _emberScheduler.task)(function* (transition, animateTask) {
// we remember the transition we're using in case we need to
// recalculate based on other animators potentially moving our DOM
// around
this._lastTransition = transition;
// Reset the sprite lists. These are component state mostly
// because beginStaticMeasurement needs to be able to put
// everything into static positioning at any point in time, so
// that any animation that's starting up can figure out what the
// DOM is going to look like.
let keptSprites = this._keptSprites = [];
let removedSprites = this._removedSprites = [];
let insertedSprites = this._insertedSprites = [];
// Start by locating our current sprites based off the actual DOM
// elements we contain. This records their initial positions.
var _findCurrentSprites = this._findCurrentSprites();
let currentSprites = _findCurrentSprites.currentSprites,
parent = _findCurrentSprites.parent;
// Warn the rest of the universe that we're about to animate.
this.get('motionService').willAnimate({
task: animateTask,
duration: this.get('durationWithDefault'),
component: this,
children: this._renderedChildren
});
// Make all our current sprites absolutely positioned so they won't move during render.
currentSprites.forEach(sprite => sprite.lock());
// Wait for Ember to render our latest state.
yield (0, _emberAnimated.afterRender)();
return { parent, currentSprites, insertedSprites, keptSprites, removedSprites };
}),
runAnimation: (0, _emberScheduler.task)(function* (transition, parent, currentSprites, insertedSprites, keptSprites, removedSprites, firstTime) {
// fill the keptSprites and removedSprites lists by comparing what
// we had in currentSprites with what is still in the DOM now that
// rendering happened.
this._partitionKeptAndRemovedSprites(currentSprites);
// perform static measurement. The motionService coordinates this
// because all animators need to be simultaneously put into their
// static state via beginStaticMeasurement and endStaticMeasurement.
yield* this.get('motionService').staticMeasurement(() => {
// we care about the final position of our own DOM parent. That
// lets us nest motions correctly.
if (parent && !parent.finalBounds) {
parent.measureFinalBounds();
}
for (let element of this._ownElements()) {
// now is when we find all the newly inserted sprites and
// remember their final bounds.
if (!currentSprites.find(sprite => sprite.element === element)) {
if (!parent) {
parent = _sprite.default.offsetParentEndingAt(element);
}
let sprite = _sprite.default.positionedEndingAt(element, parent);
sprite.owner = this._elementToChild.get(element);
sprite.hide();
insertedSprites.push(sprite);
}
}
// and remember the final bounds of all our kept sprites
keptSprites.forEach(sprite => sprite.measureFinalBounds());
});
// at this point we know all the geometry of our own sprites. But
// some of our sprites may match up with sprites that are entering
// or leaving other simulatneous animators. So we hit another
// coordination point via the motionService
var _ref3 = yield this.get('motionService.farMatch').perform((0, _scheduler.current)(), insertedSprites, keptSprites, removedSprites);
let farMatches = _ref3.farMatches,
matchingAnimatorsFinished = _ref3.matchingAnimatorsFinished,
beacons = _ref3.beacons;
// TODO: This is best effort. The parent isn't necessarily in
// the initial position at this point, but in practice if people
// are properly using animated-containers it will be locked into
// that position. We only need this if there were no elements to
// begin with. A better solution would figure out what the
// offset parent *would* be even when there are no elements,
// based on our own placeholder comment nodes.
if (parent && !parent.initialBounds) {
parent.measureInitialBounds();
}
var _partition = (0, _partition7.default)(removedSprites, sprite => {
let other = farMatches.get(sprite);
if (other) {
sprite.endAtSprite(other);
if (other.revealed && !sprite.revealed) {
sprite.startAtSprite(other);
}
return true;
}
}),
_partition2 = _slicedToArray(_partition, 2);
let sentSprites = _partition2[0],
unmatchedRemovedSprites = _partition2[1];
// if any of our inserted sprites have matching far away sprites,
// they become receivedSprites and they get initialBounds
// (derived from their far away matching sprite) and motion
// continuity via `startAtSprite`.
var _partition3 = (0, _partition7.default)(insertedSprites, sprite => {
let other = farMatches.get(sprite);
if (other) {
sprite.startAtSprite(other);
return true;
}
}),
_partition4 = _slicedToArray(_partition3, 2);
let receivedSprites = _partition4[0],
unmatchedInsertedSprites = _partition4[1];
var _partition5 = (0, _partition7.default)(keptSprites, sprite => {
let other = farMatches.get(sprite);
if (other) {
if (other.revealed && !sprite.revealed) {
sprite.startAtSprite(other);
}
return true;
}
}),
_partition6 = _slicedToArray(_partition5, 2);
let matchedKeptSprites = _partition6[0],
unmatchedKeptSprites = _partition6[1];
// let other animators make their own partitioning decisions
// before we start hiding the sent & received sprites yield
yield (0, _emberAnimated.microwait)();
matchedKeptSprites.forEach(s => s.hide());
sentSprites.forEach(s => s.hide());
// By default, we don't treat sprites as "inserted" when our
// component first renders. You can override that by setting
// initialInsertion=true.
if (firstTime && !this.get('initialInsertion')) {
// Here we are effectively hiding the inserted sprites from the
// user's transition function and just immediately revealing
// them in their final positions instead.
unmatchedInsertedSprites.forEach(s => s.reveal());
unmatchedInsertedSprites = [];
}
// Early exit if nothing is happening.
if (!transition || unmatchedInsertedSprites.length === 0 && unmatchedKeptSprites.length === 0 && unmatchedRemovedSprites.length === 0 && sentSprites.length === 0 && receivedSprites.length === 0 && matchedKeptSprites.length === 0) {
return { matchingAnimatorsFinished };
}
let context = new _transitionContext.default(this.get('durationWithDefault'), unmatchedInsertedSprites, // user-visible insertedSprites
unmatchedKeptSprites, // user-visible keptSprites
unmatchedRemovedSprites, // user-visible removedSprites
sentSprites, // user-visible sentSprites
receivedSprites.concat(matchedKeptSprites), // user-visible receivedSprites
beacons);
let cycle = this._cycleCounter++;
context.onMotionStart = sprite => this._motionStarted(sprite, cycle);
context.onMotionEnd = sprite => this._motionEnded(sprite, cycle);
yield* context._runToCompletion(transition);
return { matchingAnimatorsFinished };
}),
finalizeAnimation: (0, _emberScheduler.task)(function* (insertedSprites, keptSprites, removedSprites, matchingAnimatorsFinished) {
yield matchingAnimatorsFinished;
// The following cleanup ensures that all the sprites that will
// stay on the page after our animation are unlocked and
// revealed. We may have already revealed most of them, but if an
// inserted sprite was never subject to a motion it will appear
// here, and if a previous transition was interrupted before an
// inserted sprite could be revealed, it could have become a kept
// sprite for us.
keptSprites.forEach(sprite => {
sprite.unlock();
sprite.reveal();
});
insertedSprites.forEach(sprite => {
sprite.unlock();
sprite.reveal();
});
this._keptSprites = null;
this._removedSprites = null;
this._insertedSprites = null;
if (removedSprites.length > 0) {
// trigger a rerender to reap our removed children
this.notifyPropertyChange('renderedChildren');
// wait for the render to happen before we allow our animation
// to be done
yield (0, _emberAnimated.afterRender)();
}
}),
_motionStarted(sprite, cycle) {
sprite.reveal();
sprite.owner.block(cycle);
},
_motionEnded(sprite, cycle) {
sprite.owner.unblock(cycle);
},
_transitionFor(firstTime, oldItems, newItems) {
let rules = this.get('rules');
if (rules) {
return rules({ firstTime, oldItems, newItems });
} else {
return this.get('use');
}
}
}).reopenClass({
positionalParams: ['items']
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment