Ir para o conteúdo
jolt.
← Receitas

TOQUE E GESTOS / iOS / NATIVESCRIPT VUE

Solte um pouco de magia.

Um card que inclina, volta com spring ou desaparece em uma poção.

Do projeto Hexdrop ↗
Gravação nativa · iOSHexdrop · simulador iPhone 17 Pro, iOS 26.2. Arrastes reais via idb: retorno com spring, drop aceito, ignição, preenchimento e balanço da tampa. Interface em inglês, velocidade original, captura sem áudio. Demo local; nenhum arquivo é enviado. Não mede desempenho em aparelho físico.

O movimento por trás.

Jolt 0.1.0-rc.11

O arquivo completo do projeto, vinculado à revisão conferida. Adapte com as dependências descritas abaixo.

NativeScript-Vue 3 e Jolt RC.11. Chame useIngredientDrag no setup; conecte loaded e tap ao card nativo. Forneça disabled, limites do alvo nas coordenadas da janela, onStart, onHover e onDrop. Copie transformedRect, intersects e Rect de potion/geometry.ts na revisão indicada. Mantenha o gesto separado do scope de feedback e preserve descarte e proteções de background. O hook não desenha a poção nem envia arquivos; a cena está em potion-view.ts. Execução verificada apenas no simulador 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 }
}
Ver demo completa ↗

Leia o guia completo ↗