material-motion
Version:
Makes it easy to add rich, interactive motion to your application.
348 lines (297 loc) • 9.33 kB
text/typescript
/** @license
* Copyright 2016 - present The Material Motion 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 {
Axis,
State,
} from '../enums';
import {
anyOf,
not,
when,
} from '../aggregators';
import {
getVelocity$,
} from '../getVelocity$';
import {
MemorylessMotionSubject,
MotionProperty,
createProperty,
} from '../observables/';
import {
subscribe,
} from '../subscribe';
import {
ObservableWithMotionOperators,
Point2D,
TranslateStyleStreams,
} from '../types';
import {
Draggable,
} from './Draggable';
import {
Point2DSpring,
} from './Point2DSpring';
export type TossableArgs = {
draggable: Draggable,
spring: Point2DSpring,
};
export class Tossable {
readonly state$: MotionProperty<State> = createProperty<State>({
initialValue: State.AT_REST,
});
get state(): State {
return this.state$.read();
}
/**
* This is the point from which all other resistance calculations are
* measured.
*/
readonly resistanceOrigin$: MotionProperty<Point2D> = createProperty({
initialValue: { x: 0, y: 0 },
});
get resistanceOrigin(): Point2D {
return this.resistanceOrigin$.read();
}
set resistanceOrigin(value: Point2D) {
this.resistanceOrigin$.write(value);
}
/**
* This is the distance from the origin that an item can be freely dragged
* without encountering resistance.
*/
readonly radiusUntilResistance$: MotionProperty<number> = createProperty({
initialValue: 0,
});
get radiusUntilResistance(): number {
return this.radiusUntilResistance$.read();
}
set radiusUntilResistance(value: number) {
this.radiusUntilResistance$.write(value);
}
/**
* For carousels or swipeable lists, this is the width of one item.
*
* To apply resistance, the calculation needs to determine the amount of
* progress through a drag. `resistanceBasis` is the denominator in this
* calculation. For instance, if a drag is 20px beyond `radiusUntilResistance`
* and `resistanceBasis` is 50, the drag progress used by the resistance
* calculation is 40%.
*
* Note: a drag cannot move farther than `resistanceBasis` beyond
* `radiusUntilResistance`.
*/
readonly resistanceBasis$: MotionProperty<number> = createProperty({
initialValue: 0,
});
get resistanceBasis(): number {
return this.resistanceBasis$.read();
}
set resistanceBasis(value: number) {
this.resistanceBasis$.write(value);
}
/**
* This value determines how far beyond `radiusUntilResistance` a drag is
* limited to.
*
* It works in conjunction with `resistanceBasis`. If `resistanceBasis` is 50
* and `resistanceFactor` is 5, the drag is limited to 10px (basis / factor)
* beyond `radiusUntilResistance`.
*/
readonly resistanceFactor$: MotionProperty<number> = createProperty({
initialValue: 0,
});
get resistanceFactor(): number {
return this.resistanceFactor$.read();
}
set resistanceFactor(value: number) {
this.resistanceFactor$.write(value);
}
readonly location$: MotionProperty<Point2D> = createProperty({
initialValue: { x: 0, y: 0 },
});
readonly velocity$: ObservableWithMotionOperators<Point2D>;
readonly draggedLocation$: ObservableWithMotionOperators<Point2D>;
readonly draggable: Draggable;
readonly spring: Point2DSpring;
readonly styleStreams: TranslateStyleStreams;
constructor({ draggable, spring }: TossableArgs) {
this.draggable = draggable;
this.spring = spring;
const dragIsAtRest$ = draggable.state$.rewrite<boolean, boolean>({
mapping: {
[State.AT_REST]: true,
[State.ACTIVE]: false,
}
}).dedupe();
const whenDragIsAtRest$ = when(dragIsAtRest$);
const whenDragIsActive$ = when(not(dragIsAtRest$));
// This block needs to come before the one that sets spring enabled to
// ensure the spring initializes with the correct values; otherwise, it will
// start from 0
subscribe({
sink: spring.initialValue$,
source: this.location$._debounce({ pulse$: whenDragIsAtRest$}),
});
const locationOnDown$ = this.location$._debounce({ pulse$: whenDragIsActive$ });
this.draggedLocation$ = draggable.value$.addedBy<Point2D>({
value$: locationOnDown$,
onlyEmitWithUpstream: true
})._reactiveMap({
transform: ({
upstream: location,
resistanceOrigin,
radiusUntilResistance,
resistanceBasis,
resistanceFactor,
}) => {
if (!resistanceFactor) {
return location;
}
// We apply resistance radially, leading to all the trig below. In most
// cases, the draggable element will be axis locked, which means there's
// room to short circuit the logic here with simpler solutions when we
// know either x or y is constant.
const locationFromOrigin: Point2D = {
x: location.x - resistanceOrigin.x,
y: location.y - resistanceOrigin.y,
};
const overflowRadius = Math.sqrt(locationFromOrigin.x ** 2 + locationFromOrigin.y ** 2) - radiusUntilResistance;
const resistanceProgress = Math.max(0, Math.min(1, overflowRadius / resistanceBasis));
if (overflowRadius < 0) {
return location;
}
const radiusWithResistance = resistanceBasis / resistanceFactor * Math.sin(resistanceProgress * Math.PI / 2) + radiusUntilResistance;
const angle = Math.atan2(locationFromOrigin.y, locationFromOrigin.x);
return {
x: resistanceOrigin.x + radiusWithResistance * Math.cos(angle),
y: resistanceOrigin.y + radiusWithResistance * Math.sin(angle),
};
},
inputs: {
resistanceOrigin: this.resistanceOrigin$,
radiusUntilResistance: this.radiusUntilResistance$,
resistanceBasis: this.resistanceBasis$,
resistanceFactor: this.resistanceFactor$,
},
onlyEmitWithUpstream: true,
});
this.velocity$ = getVelocity$({
// Since drag starts at rest, whenDragIsAtRest$ emits immediately. Thus,
// we start with { 0, 0 } to ensure velocity doesn't emit undefined.
value$: this.draggedLocation$.startWith({ x: 0, y: 0 }),
pulse$: whenDragIsAtRest$,
});
subscribe({
sink: spring.initialVelocity$,
source: this.velocity$,
});
subscribe({
sink: spring.enabled$,
source: dragIsAtRest$,
});
subscribe({
sink: this.state$,
source: anyOf([
spring.state$.isAnyOf([ State.ACTIVE ]),
draggable.state$.isAnyOf([ State.ACTIVE ]),
]).rewrite({
mapping: {
true: State.ACTIVE,
false: State.AT_REST,
},
}).dedupe(),
});
subscribe({
sink: this.location$,
source: spring.enabled$.rewrite<Point2D, ObservableWithMotionOperators<Point2D>>({
mapping: {
true: spring.value$,
false: this.draggedLocation$,
},
emitOnKeyChange: false,
})._debounce(),
});
this.styleStreams = {
translate$: this.location$,
willChange$: this.state$.rewrite<string, string>({
mapping: {
[State.ACTIVE]: 'transform',
[State.AT_REST]: '',
},
}),
};
}
}
export default Tossable;
export type ApplyLinearResistanceToTossableArgs = {
tossable: Tossable,
min$: ObservableWithMotionOperators<number>,
max$: ObservableWithMotionOperators<number>,
axis$: ObservableWithMotionOperators<Axis>,
basis$: ObservableWithMotionOperators<number>,
factor$: ObservableWithMotionOperators<number>,
};
export function applyLinearResistanceToTossable({
tossable,
min$,
max$,
axis$,
basis$,
factor$,
}: ApplyLinearResistanceToTossableArgs) {
subscribe({
sink: tossable.resistanceBasis$,
source: basis$,
});
subscribe({
sink: tossable.resistanceFactor$,
source: factor$,
});
subscribe({
sink: tossable.radiusUntilResistance$,
source: min$._reactiveMap({
transform: ({ upstream: min, max }) => Math.abs(max - min) / 2,
inputs: {
max: max$,
}
}),
});
subscribe({
sink: tossable.resistanceOrigin$,
source: axis$._reactiveMap({
transform: ({ upstream: axis, min, max }) => {
const linearCenter = min + (max - min) / 2;
if (axis === Axis.X) {
return {
x: linearCenter,
y: 0,
};
} else if (axis === Axis.Y) {
return {
x: 0,
y: linearCenter,
};
} else {
console.warn(`Cannot apply linear resistance if axis isn't locked`);
}
},
inputs: {
min: min$,
max: max$,
},
}),
});
}