跳转到正文
jolt.
← 动效配方

触控与手势 / iOS / NATIVESCRIPT VUE

拖出一点魔法。

文件卡片倾斜跟手,弹回原位,或融入魔药。

来自 Hexdrop ↗
原生录屏 · iOSHexdrop · iPhone 17 Pro 模拟器,iOS 26.2。真实 idb 拖动:弹回、成功投放、点火、填充和瓶塞摆动。英文界面,原始速度,无声录屏。仅为本地交互演示,不上传文件。非真机性能测试。

动效背后的代码。

Jolt 0.1.0-rc.11

来自项目的完整源文件,固定到已核对的版本。请结合下面的依赖说明进行适配。

需要 NativeScript-Vue 3 和 Jolt RC.11。在 setup 中调用 useIngredientDrag,将 loaded 和 tap 连接到原生卡片。提供 disabled、窗口坐标系中的目标边界、onStart、onHover 和 onDrop。复制固定版本 potion/geometry.ts 中的 transformedRect、intersects 和 Rect。将手势所有权与反馈 scope 分开,保留清理和后台保护。此 hook 不绘制魔药,也不上传文件;完整场景在 potion-view.ts 中。仅验证了 iOS 模拟器运行。

useIngredientDrag.ts
import { onBeforeUnmount, ref, watch, type Ref } from 'nativescript-vue'
import { Application, type EventData, type View } from '@nativescript/core'
import { drag, motionScope, set, type Drag, type DragPoint, type DragRelease } from '@rafaelaugusto/jolt'
import { intersects, transformedRect, type Rect } from '../potion/geometry'

interface IngredientDragOptions {
  disabled: Ref<boolean>
  target: () => Rect | undefined
  onStart: () => void
  onHover: (active: boolean) => void
  onDrop: () => void
}

const REST = { x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 }
const DRAG_SCALE = 0.86
const tilt = (x: number) => Math.max(-8, Math.min(8, x / 16))

/** Jolt owns tracking and snap physics; this adapter decides whether a drop lands. */
export function useIngredientDrag(options: IngredientDragOptions) {
  const dragging = ref(false)
  const motion = motionScope()
  let card: View | undefined
  let controller: Drag | undefined
  let origin: Rect | undefined
  let generation = 0
  let ignoreTapUntil = 0
  let hovering = false

  function setHover(active: boolean) {
    if (hovering === active) return
    hovering = active
    options.onHover(active)
  }

  function hitsTarget(point: DragPoint) {
    const target = options.target()
    return !!origin && !!target && intersects(
      transformedRect(origin, point, DRAG_SCALE, tilt(point.x)), target,
    )
  }

  function restore() {
    generation++
    motion.reset()
    origin = undefined
    dragging.value = false
    ignoreTapUntil = Date.now() + 500
    setHover(false)
    if (card) set(card, REST)
  }

  function start(point: DragPoint) {
    if (!card || options.disabled.value || Application.suspended) {
      controller?.cancel()
      return
    }
    generation++
    motion.reset()
    options.onStart()
    // Measure the layout origin, even when catching a card during its return.
    set(card, { scale: 1, rotate: 0 })
    const location = card.getLocationInWindow()
    origin = { x: location.x - point.x, y: location.y - point.y, ...card.getActualSize() }
    dragging.value = true
    ignoreTapUntil = Date.now() + 500
    set(card, { scale: DRAG_SCALE })
  }

  async function release(event: DragRelease) {
    if (!card || !origin || options.disabled.value || Application.suspended) {
      controller?.cancel()
      restore()
      return
    }
    const id = generation
    // Check the final release position: it need not have produced an onMove.
    const accepted = hitsTarget(event)
    setHover(false)
    ignoreTapUntil = Date.now() + 500
    if (accepted) {
      controller?.pause()
      const finished = await motion.animate(card, { scale: 0.08, opacity: 0 }, { duration: 200, ease: 'easeIn' }).finished
      if (id !== generation) return
      restore()
      if (finished && !Application.suspended && !options.disabled.value) options.onDrop()
      if (!options.disabled.value) controller?.play()
    } else {
      // snap: [0] returns both axes using Jolt's release velocity and native spring.
      const appearance = motion.animate(card, { rotate: 0, scale: 1 }, 'bouncy')
      await Promise.all([event.animation.finished, appearance.finished])
      if (id === generation) restore()
    }
  }

  function loaded(event: EventData) {
    const view = event.object as View
    if (view === card && controller) return
    controller?.dispose()
    restore()
    card = view
    // Keep the gesture separate from the visual scope: resetting feedback must
    // not detach the recognizer. The controller handles unload/background itself.
    controller = drag(view, {
      snap: [0],
      onStart: start,
      onMove: point => {
        if (!origin) return
        set(view, { rotate: tilt(point.x) })
        setHover(hitsTarget(point))
      },
      onRelease: event => { void release(event) },
      onCancel: restore,
    })
    if (options.disabled.value) controller.pause()
  }

  function tap() {
    if (Application.suspended || options.disabled.value || dragging.value || Date.now() < ignoreTapUntil) return
    options.onDrop()
  }

  watch(options.disabled, disabled => {
    if (disabled) { controller?.pause(); restore() }
    else controller?.play()
  }, { flush: 'sync' })

  // A release has no active contact for Jolt to cancel. Invalidate its pending
  // absorption callback too, so backgrounding cannot start an invisible brew.
  Application.on(Application.suspendEvent, restore)
  const resume = () => { if (!options.disabled.value) controller?.play() }
  Application.on(Application.resumeEvent, resume)
  onBeforeUnmount(() => {
    controller?.dispose()
    restore()
    motion.dispose()
    Application.off(Application.suspendEvent, restore)
    Application.off(Application.resumeEvent, resume)
  })

  return { dragging, loaded, tap }
}
查看完整示例 ↗

阅读完整指南 ↗