diff --git a/packages/core/useTransition/index.md b/packages/core/useTransition/index.md index 0eef3d5c272..607a57524cd 100644 --- a/packages/core/useTransition/index.md +++ b/packages/core/useTransition/index.md @@ -8,7 +8,7 @@ Transition between values ## Usage -For simple transitions, provide a numeric source value to watch. When changed, the output will transition to the new value. If the source changes while a transition is in progress, a new transition will begin from where the previous one was interrupted. +Define a numeric source value to follow, and when changed the output will transition to the new value. If the source changes while a transition is in progress, a new transition will begin from where the previous one was interrupted. ```js import { ref } from 'vue' @@ -102,3 +102,13 @@ useTransition(source, { ``` To temporarily stop transitioning, define a boolean `disabled` property. Be aware, this is not the same a `duration` of `0`. Disabled transitions track the source value **_synchronously_**. They do not respect a `delay`, and do not fire `onStarted` or `onFinished` callbacks. + +For more control, transitions can be executed manually by using `executeTransition`. This function returns a promise that resolves upon completion. Manual transitions can be cancelled by defining an `abort` function that returns a truthy value. + +```js +import { executeTransition } from '@vueuse/core' + +await executeTransition(source, from, to, { + duration: 1000, +}) +``` diff --git a/packages/core/useTransition/index.test.ts b/packages/core/useTransition/index.test.ts index 389a1e5c980..cc3e2c2c531 100644 --- a/packages/core/useTransition/index.test.ts +++ b/packages/core/useTransition/index.test.ts @@ -1,12 +1,65 @@ import { promiseTimeout } from '@vueuse/shared' import { ref } from 'vue-demi' -import { useTransition } from '.' +import { executeTransition, useTransition } from '.' const expectBetween = (val: number, floor: number, ceiling: number) => { expect(val).to.be.greaterThan(floor) expect(val).to.be.lessThan(ceiling) } +describe('executeTransition', () => { + it('transitions between numbers', async () => { + const source = ref(0) + + const trans = executeTransition(source, 0, 1, { duration: 50 }) + + await promiseTimeout(25) + + expectBetween(source.value, 0.25, 0.75) + + await trans + + expect(source.value).toBe(1) + }) + + it('transitions between vectors', async () => { + const source = ref([0, 0, 0]) + + const trans = executeTransition(source, [0, 1, 2], [1, 2, 3], { duration: 50 }) + + await promiseTimeout(25) + + expectBetween(source.value[0], 0, 1) + expectBetween(source.value[1], 1, 2) + expectBetween(source.value[2], 2, 3) + + await trans + + expect(source.value[0]).toBe(1) + expect(source.value[1]).toBe(2) + expect(source.value[2]).toBe(3) + }) + + it('transitions can be aborted', async () => { + let abort = false + + const source = ref(0) + + const trans = executeTransition(source, 0, 1, { + abort: () => abort, + duration: 50, + }) + + await promiseTimeout(25) + + abort = true + + await trans + + expectBetween(source.value, 0, 1) + }) +}) + describe('useTransition', () => { it('transitions between numbers', async () => { const source = ref(0) @@ -227,4 +280,22 @@ describe('useTransition', () => { disabled.value = false expect(transition.value).toBe(1) }) + + it('begins transition from where previous transition was interrupted', async () => { + const source = ref(0) + + const transition = useTransition(source, { + duration: 100, + }) + + source.value = 1 + + await promiseTimeout(50) + + source.value = 0 + + await promiseTimeout(25) + + expectBetween(transition.value, 0, 0.5) + }) }) diff --git a/packages/core/useTransition/index.ts b/packages/core/useTransition/index.ts index a69b53a20f2..92249267096 100644 --- a/packages/core/useTransition/index.ts +++ b/packages/core/useTransition/index.ts @@ -1,8 +1,7 @@ -import type { ComputedRef, Ref } from 'vue-demi' import { computed, ref, unref, watch } from 'vue-demi' +import { isFunction, isNumber, identity as linear, promiseTimeout, tryOnScopeDispose } from '@vueuse/shared' +import type { ComputedRef, Ref } from 'vue-demi' import type { MaybeRef } from '@vueuse/shared' -import { clamp, isFunction, isNumber, identity as linear, noop, useTimeoutFn } from '@vueuse/shared' -import { useRafFn } from '../useRafFn' /** * Cubic bezier points @@ -17,7 +16,25 @@ export type EasingFunction = (n: number) => number /** * Transition options */ -export interface UseTransitionOptions { +export interface TransitionOptions { + + /** + * Manually abort a transition + */ + abort?: () => any + + /** + * Transition duration in milliseconds + */ + duration?: MaybeRef + + /** + * Easing function or cubic bezier points for calculating transition values + */ + transition?: MaybeRef +} + +export interface UseTransitionOptions extends TransitionOptions { /** * Milliseconds to wait before starting transition */ @@ -28,11 +45,6 @@ export interface UseTransitionOptions { */ disabled?: MaybeRef - /** - * Transition duration in milliseconds - */ - duration?: MaybeRef - /** * Callback to execute after transition finishes */ @@ -42,11 +54,6 @@ export interface UseTransitionOptions { * Callback to execute after transition starts */ onStarted?: () => void - - /** - * Easing function or cubic bezier points for calculating transition values - */ - transition?: MaybeRef } const _TransitionPresets = { @@ -115,6 +122,68 @@ function createEasingFunction([p0, p1, p2, p3]: CubicBezierPoints): EasingFuncti return (x: number) => (p0 === p1 && p2 === p3) ? x : calcBezier(getTforX(x), p1, p3) } +const lerp = (a: number, b: number, alpha: number) => a + alpha * (b - a) + +const toVec = (t: number | number[] | undefined) => (isNumber(t) ? [t] : t) || [] + +/** + * Transition from one value to another. + * + * @param source + * @param from + * @param to + * @param options + */ +export function executeTransition( + source: Ref, + from: MaybeRef, + to: MaybeRef, + options: TransitionOptions = {}, +): PromiseLike { + const fromVal = unref(from) + const toVal = unref(to) + const v1 = toVec(fromVal) + const v2 = toVec(toVal) + const duration = unref(options.duration) ?? 1000 + const startedAt = Date.now() + const endAt = Date.now() + duration + const trans = unref(options.transition) ?? linear + + const ease = isFunction(trans) ? trans : createEasingFunction(trans) + + return new Promise((resolve) => { + source.value = fromVal + + const tick = () => { + if (options.abort?.()) { + resolve() + + return + } + + const now = Date.now() + const alpha = ease((now - startedAt) / duration) + const arr = toVec(source.value).map((n, i) => lerp(v1[i], v2[i], alpha)) + + if (Array.isArray(source.value)) + (source.value as number[]) = arr.map((n, i) => lerp(v1[i] ?? 0, v2[i] ?? 0, alpha)) + else if (isNumber(source.value)) + (source.value as number) = arr[0] + + if (now < endAt) { + requestAnimationFrame(tick) + } + else { + source.value = toVal + + resolve() + } + } + + tick() + }) +} + // option 1: reactive number export function useTransition(source: Ref, options?: UseTransitionOptions): ComputedRef @@ -125,7 +194,7 @@ export function useTransition[]>(source: [...T], opti export function useTransition>(source: T, options?: UseTransitionOptions): ComputedRef /** - * Transition between values. + * Follow value with a transition. * * @see https://vueuse.org/useTransition * @param source @@ -134,87 +203,52 @@ export function useTransition>(source: T, options?: UseT export function useTransition( source: Ref | MaybeRef[], options: UseTransitionOptions = {}, -): ComputedRef { - const { - delay = 0, - disabled = false, - duration = 1000, - onFinished = noop, - onStarted = noop, - transition = linear, - } = options - - // current easing function - const currentTransition = computed(() => { - const t = unref(transition) - return isFunction(t) ? t : createEasingFunction(t) - }) - - // raw source value - const sourceValue = computed(() => { - const s = unref[]>(source) - return isNumber(s) ? s : s.map(unref) as number[] - }) +): Ref { + let currentId = 0 - // normalized source vector - const sourceVector = computed(() => isNumber(sourceValue.value) ? [sourceValue.value] : sourceValue.value) + const sourceVal = () => { + const v = unref[]>(source) - // transitioned output vector - const outputVector = ref(sourceVector.value.slice(0)) + return isNumber(v) ? v : v.map(unref) + } - // current transition values - let currentDuration: number - let diffVector: number[] - let endAt: number - let startAt: number - let startVector: number[] + const outputRef = ref(sourceVal()) - // transition loop - const { resume, pause } = useRafFn(() => { - const now = Date.now() - const progress = clamp(1 - ((endAt - now) / currentDuration), 0, 1) + watch(source, async (to) => { + if (unref(options.disabled)) + return - outputVector.value = startVector.map((val, i) => val + ((diffVector[i] ?? 0) * currentTransition.value(progress))) + const id = ++currentId - if (progress >= 1) { - pause() - onFinished() - } - }, { immediate: false }) + if (options.delay) + await promiseTimeout(unref(options.delay)) - // start the animation loop when source vector changes - const start = () => { - pause() + if (id !== currentId) + return - currentDuration = unref(duration) - diffVector = outputVector.value.map((n, i) => (sourceVector.value[i] ?? 0) - (outputVector.value[i] ?? 0)) - startVector = outputVector.value.slice(0) - startAt = Date.now() - endAt = startAt + currentDuration + const toVal = Array.isArray(to) ? to.map(unref) : unref(to) - resume() - onStarted() - } + options.onStarted?.() - const timeout = useTimeoutFn(start, delay, { immediate: false }) + await executeTransition(outputRef, outputRef.value, toVal, { + ...options, + abort: () => id !== currentId || options.abort?.(), + }) - watch(sourceVector, () => { - if (unref(disabled)) - return - if (unref(delay) <= 0) - start() - else timeout.start() + options.onFinished?.() }, { deep: true }) - watch(() => unref(disabled), (v) => { - if (v) { - outputVector.value = sourceVector.value.slice(0) - pause() + watch(() => unref(options.disabled), (disabled) => { + if (disabled) { + currentId++ + + outputRef.value = sourceVal() } }) - return computed(() => { - const targetVector = unref(disabled) ? sourceVector : outputVector - return isNumber(sourceValue.value) ? targetVector.value[0] : targetVector.value + tryOnScopeDispose(() => { + currentId++ }) + + return computed(() => unref(options.disabled) ? sourceVal() : outputRef.value) }