fix(animations): properly support boolean-based transitions and state changes (#19279)

Closes #9396
Closes #12337

PR Close #19279
This commit is contained in:
Matias Niemelä 2017-09-19 14:22:22 -07:00 committed by Chuck Jazdzewski
parent b1ca5d4ddf
commit c4704c8abc
4 changed files with 146 additions and 18 deletions

View File

@ -65,16 +65,27 @@ function parseAnimationAlias(alias: string, errors: string[]): string|Transition
} }
} }
const TRUE_BOOLEAN_VALUES = new Set<string>();
TRUE_BOOLEAN_VALUES.add('true');
TRUE_BOOLEAN_VALUES.add('1');
const FALSE_BOOLEAN_VALUES = new Set<string>();
FALSE_BOOLEAN_VALUES.add('false');
FALSE_BOOLEAN_VALUES.add('0');
function makeLambdaFromStates(lhs: string, rhs: string): TransitionMatcherFn { function makeLambdaFromStates(lhs: string, rhs: string): TransitionMatcherFn {
const LHS_MATCH_BOOLEAN = TRUE_BOOLEAN_VALUES.has(lhs) || FALSE_BOOLEAN_VALUES.has(lhs);
const RHS_MATCH_BOOLEAN = TRUE_BOOLEAN_VALUES.has(rhs) || FALSE_BOOLEAN_VALUES.has(rhs);
return (fromState: any, toState: any): boolean => { return (fromState: any, toState: any): boolean => {
let lhsMatch = lhs == ANY_STATE || lhs == fromState; let lhsMatch = lhs == ANY_STATE || lhs == fromState;
let rhsMatch = rhs == ANY_STATE || rhs == toState; let rhsMatch = rhs == ANY_STATE || rhs == toState;
if (!lhsMatch && typeof fromState === 'boolean') { if (!lhsMatch && LHS_MATCH_BOOLEAN && typeof fromState === 'boolean') {
lhsMatch = fromState ? lhs === 'true' : lhs === 'false'; lhsMatch = fromState ? TRUE_BOOLEAN_VALUES.has(lhs) : FALSE_BOOLEAN_VALUES.has(lhs);
} }
if (!rhsMatch && typeof toState === 'boolean') { if (!rhsMatch && RHS_MATCH_BOOLEAN && typeof toState === 'boolean') {
rhsMatch = toState ? rhs === 'true' : rhs === 'false'; rhsMatch = toState ? TRUE_BOOLEAN_VALUES.has(rhs) : FALSE_BOOLEAN_VALUES.has(rhs);
} }
return lhsMatch && rhsMatch; return lhsMatch && rhsMatch;

View File

@ -119,18 +119,18 @@ export class AnimationTransitionNamespace {
listen(element: any, name: string, phase: string, callback: (event: any) => boolean): () => any { listen(element: any, name: string, phase: string, callback: (event: any) => boolean): () => any {
if (!this._triggers.hasOwnProperty(name)) { if (!this._triggers.hasOwnProperty(name)) {
throw new Error( throw new Error(`Unable to listen on the animation trigger event "${
`Unable to listen on the animation trigger event "${phase}" because the animation trigger "${name}" doesn\'t exist!`); phase}" because the animation trigger "${name}" doesn\'t exist!`);
} }
if (phase == null || phase.length == 0) { if (phase == null || phase.length == 0) {
throw new Error( throw new Error(`Unable to listen on the animation trigger "${
`Unable to listen on the animation trigger "${name}" because the provided event is undefined!`); name}" because the provided event is undefined!`);
} }
if (!isTriggerEventValid(phase)) { if (!isTriggerEventValid(phase)) {
throw new Error( throw new Error(`The provided animation trigger event "${phase}" for the animation trigger "${
`The provided animation trigger event "${phase}" for the animation trigger "${name}" is not supported!`); name}" is not supported!`);
} }
const listeners = getOrSetAsInMap(this._elementListeners, element, []); const listeners = getOrSetAsInMap(this._elementListeners, element, []);
@ -802,7 +802,8 @@ export class TransitionAnimationEngine {
reportError(errors: string[]) { reportError(errors: string[]) {
throw new Error( throw new Error(
`Unable to process animations due to the following failed trigger transitions\n ${errors.join("\n")}`); `Unable to process animations due to the following failed trigger transitions\n ${
errors.join('\n')}`);
} }
private _flushAnimations(cleanupFns: Function[], microtaskId: number): private _flushAnimations(cleanupFns: Function[], microtaskId: number):
@ -1411,13 +1412,11 @@ function deleteOrUnsetInMap(map: Map<any, any[]>| {[key: string]: any}, key: any
return currentValues; return currentValues;
} }
function normalizeTriggerValue(value: any): string { function normalizeTriggerValue(value: any): any {
switch (typeof value) { // we use `!= null` here because it's the most simple
case 'boolean': // way to test against a "falsy" value without mixing
return value ? '1' : '0'; // in empty strings or a zero value. DO NOT OPTIMIZE.
default: return value != null ? value : null;
return value != null ? value.toString() : null;
}
} }
function isElementNode(node: any) { function isElementNode(node: any) {

View File

@ -257,6 +257,11 @@ export interface AnimationStaggerMetadata extends AnimationMetadata {
the the
* trigger is bound to (in the form of `[@triggerName]="expression"`. * trigger is bound to (in the form of `[@triggerName]="expression"`.
* *
* Animation trigger bindings strigify values and then match the previous and current values against
* any linked transitions. If a boolean value is provided into the trigger binding then it will both
* be represented as `1` or `true` and `0` or `false` for a true and false boolean values
* respectively.
*
* ### Usage * ### Usage
* *
* `trigger` will create an animation trigger reference based on the provided `name` value. The * `trigger` will create an animation trigger reference based on the provided `name` value. The
@ -734,6 +739,22 @@ export function keyframes(steps: AnimationStyleMetadata[]): AnimationKeyframesSe
* ]) * ])
* ``` * ```
* *
* ### Boolean values
* if a trigger binding value is a boolean value then it can be matched using a transition
* expression that compares `true` and `false` or `1` and `0`.
*
* ```
* // in the template
* <div [@openClose]="open ? true : false">...</div>
*
* // in the component metadata
* trigger('openClose', [
* state('true', style({ height: '*' })),
* state('false', style({ height: '0px' })),
* transition('false <=> true', animate(500))
* ])
* ```
*
* ### Using :increment and :decrement * ### Using :increment and :decrement
* In addition to the :enter and :leave transition aliases, the :increment and :decrement aliases * In addition to the :enter and :leave transition aliases, the :increment and :decrement aliases
* can be used to kick off a transition when a numeric value has increased or decreased in value. * can be used to kick off a transition when a numeric value has increased or decreased in value.

View File

@ -475,6 +475,103 @@ export function main() {
]); ]);
}); });
it('should understand boolean values as `true` and `false` for transition animations', () => {
@Component({
selector: 'if-cmp',
template: `
<div [@myAnimation]="exp"></div>
`,
animations: [
trigger(
'myAnimation',
[
transition(
'true => false',
[
style({opacity: 0}),
animate(1234, style({opacity: 1})),
]),
transition(
'false => true',
[
style({opacity: 1}),
animate(4567, style({opacity: 0})),
])
]),
]
})
class Cmp {
exp: any = false;
}
TestBed.configureTestingModule({declarations: [Cmp]});
const engine = TestBed.get(ɵAnimationEngine);
const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
cmp.exp = true;
fixture.detectChanges();
cmp.exp = false;
fixture.detectChanges();
let players = getLog();
expect(players.length).toEqual(1);
let [player] = players;
expect(player.duration).toEqual(1234);
});
it('should understand boolean values as `true` and `false` for transition animations and apply the corresponding state() value',
() => {
@Component({
selector: 'if-cmp',
template: `
<div [@myAnimation]="exp"></div>
`,
animations: [
trigger(
'myAnimation',
[
state('true', style({color: 'red'})),
state('false', style({color: 'blue'})),
transition(
'true <=> false',
[
animate(1000, style({color: 'gold'})),
animate(1000),
]),
]),
]
})
class Cmp {
exp: any = false;
}
TestBed.configureTestingModule({declarations: [Cmp]});
const engine = TestBed.get(ɵAnimationEngine);
const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
cmp.exp = false;
fixture.detectChanges();
cmp.exp = true;
fixture.detectChanges();
let players = getLog();
expect(players.length).toEqual(1);
let [player] = players;
expect(player.keyframes).toEqual([
{color: 'blue', offset: 0},
{color: 'gold', offset: 0.5},
{color: 'red', offset: 1},
]);
});
it('should not throw an error if a trigger with the same name exists in separate components', it('should not throw an error if a trigger with the same name exists in separate components',
() => { () => {
@Component({selector: 'cmp1', template: '...', animations: [trigger('trig', [])]}) @Component({selector: 'cmp1', template: '...', animations: [trigger('trig', [])]})