Skip to content

Commit

Permalink
feat(useTransition): expose transition utility for manual control (#2743
Browse files Browse the repository at this point in the history
)
  • Loading branch information
scottbedard committed Mar 4, 2023
1 parent df97961 commit 526d5c7
Show file tree
Hide file tree
Showing 3 changed files with 198 additions and 83 deletions.
12 changes: 11 additions & 1 deletion packages/core/useTransition/index.md
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
})
```
73 changes: 72 additions & 1 deletion 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)
Expand Down Expand Up @@ -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)
})
})
196 changes: 115 additions & 81 deletions 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
Expand All @@ -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<number>

/**
* Easing function or cubic bezier points for calculating transition values
*/
transition?: MaybeRef<EasingFunction | CubicBezierPoints>
}

export interface UseTransitionOptions extends TransitionOptions {
/**
* Milliseconds to wait before starting transition
*/
Expand All @@ -28,11 +45,6 @@ export interface UseTransitionOptions {
*/
disabled?: MaybeRef<boolean>

/**
* Transition duration in milliseconds
*/
duration?: MaybeRef<number>

/**
* Callback to execute after transition finishes
*/
Expand All @@ -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<EasingFunction | CubicBezierPoints>
}

const _TransitionPresets = {
Expand Down Expand Up @@ -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<T extends number | number[]>(
source: Ref<T>,
from: MaybeRef<T>,
to: MaybeRef<T>,
options: TransitionOptions = {},
): PromiseLike<void> {
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<void>((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<number>, options?: UseTransitionOptions): ComputedRef<number>

Expand All @@ -125,7 +194,7 @@ export function useTransition<T extends MaybeRef<number>[]>(source: [...T], opti
export function useTransition<T extends Ref<number[]>>(source: T, options?: UseTransitionOptions): ComputedRef<number[]>

/**
* Transition between values.
* Follow value with a transition.
*
* @see https://vueuse.org/useTransition
* @param source
Expand All @@ -134,87 +203,52 @@ export function useTransition<T extends Ref<number[]>>(source: T, options?: UseT
export function useTransition(
source: Ref<number | number[]> | MaybeRef<number>[],
options: UseTransitionOptions = {},
): ComputedRef<any> {
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<number | MaybeRef<number>[]>(source)
return isNumber(s) ? s : s.map(unref) as number[]
})
): Ref<any> {
let currentId = 0

// normalized source vector
const sourceVector = computed(() => isNumber(sourceValue.value) ? [sourceValue.value] : sourceValue.value)
const sourceVal = () => {
const v = unref<number | MaybeRef<number>[]>(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)
}

0 comments on commit 526d5c7

Please sign in to comment.