Skip to content

Complete native recipes ​

These runnable Core NativeScript screens show the full integration, including view creation and cleanup. They use public APIs and work without a framework. The Vue preview only mounts their Frame. Each source below is the exact file compiled by the demo.

Run the compiled demo with --recipe=gallery, --recipe=sheet or --recipe=lists. Android uses the corresponding recipe string intent extra. The supported list is Core ListView; other routers and virtualization plugins need their own target resolver. These captures establish simulator behavior, not physical-device performance.

Use stable item keys and resolve the current list cell at return time. Keep image sources owned by each view through src; sharing a mutable ImageSource wrapper across recycled views can clear another view’s pixels. The Frame callback applies Jolt’s route options before going back. The viewer holds the dismissal pose until navigation accepts or cancels it.

iPhone 17 simulator · iOS 26.2. Real XCTest taps and drags; English UI, original speed. Zoom, pan, cancelled dismissal, moving the selected item to the end, keyed thumbnail return and another drag-to-close.
ts
import {
  GridLayout,
  Image,
  ListView,
  StackLayout,
  knownFolders,
  type Frame,
  type View,
} from '@nativescript/core'
import { zoomable, type Zoomable } from '@rafaelaugusto/jolt'
import {
  resolveListTarget,
  sharedNavigationEntry,
} from '@rafaelaugusto/jolt/shared-navigation'
import { button, label, page } from './ui'

/** Search only current native children; a recycled cell is never a saved identity. */
function currentImage(view: View, tag: string): Image | undefined {
  if (view instanceof Image && view.sharedTransitionTag === tag) return view
  let found: Image | undefined
  view.eachChildView((child) => {
    found = currentImage(child, tag)
    return !found
  })
  return found
}

export function galleryRecipe(frame: Frame) {
  const screen = page(),
    root = new GridLayout(),
    list = new ListView()
  root.rows = 'auto,*'
  screen.content = root
  const heading = label('A complete gallery recipe')
  root.addChild(heading)
  const pictures = ['cove', 'dunes'].map(
    (name) => `${knownFolders.currentApp().path}/assets/studio/${name}.png`,
  )
  let items = Array.from({ length: 60 }, (_, i) => ({
    key: `place-${i + 1}`,
    source: pictures[i % 2]!,
  }))
  list.row = 1
  list.rowHeight = 142
  list.items = items
  list.automationText = 'recipe-gallery-list'
  const tag = (key: string) => `recipe:${key}`
  list.on('itemLoading', (event) => {
    const photo = (event.view as Image) ?? new Image(),
      item = items[event.index]!
    Object.assign(photo, {
      src: item.source,
      height: 126,
      width: 260,
      margin: 8,
      stretch: 'aspectFill',
      sharedTransitionTag: tag(item.key),
      automationText: `recipe-${item.key}`,
      accessibilityLabel: `Open ${item.key}`,
    })
    event.view = photo
  })
  root.addChild(list)
  let opening = false
  list.on('itemTap', async (event) => {
    if (opening) return
    opening = true
    const item = items[event.index]!
    let viewer: Zoomable | undefined
    const detail = page(),
      host = new GridLayout(),
      viewport = new GridLayout()
    const backdrop = new GridLayout(),
      chrome = new StackLayout(),
      photo = new Image()
    host.rows = '*,auto'
    detail.content = host
    viewport.clipToBounds = true
    backdrop.rowSpan = 2
    backdrop.backgroundColor = '#102922'
    chrome.row = 1
    photo.src = item.source
    photo.stretch = 'aspectFit'
    photo.sharedTransitionTag = tag(item.key)
    photo.automationText = 'recipe-detail-photo'
    viewport.addChild(photo)
    host.addChild(backdrop)
    host.addChild(viewport)
    host.addChild(chrome)
    const entry = sharedNavigationEntry(frame, {
      tag: tag(item.key),
      canBegin: () => !viewer || (viewer.state.scale <= 1.001 && !viewer.state.pending),
      back: (options) => {
        if (!frame.canGoBack()) return false
        Object.assign(frame.currentEntry!, options)
        frame.goBack()
        return true
      },
      returnTarget: {
        key: item.key,
        fallbackFocus: () => heading,
        resolve: (context) =>
          resolveListTarget(context, {
            list,
            indexOf: (key) => items.findIndex((value) => value.key === key),
            viewForKey: (key) => currentImage(list, tag(key)),
          }),
      },
    })
    chrome.addChild(button('Zoom photo', () => viewer?.zoomTo(2.5)))
    chrome.addChild(button('Reset photo', () => viewer?.reset()))
    chrome.addChild(
      button('Move photo to end', () => {
        items = [...items.filter((value) => value.key !== item.key), item]
        list.items = items
      }),
    )
    chrome.addChild(button('Close photo', () => viewer?.requestDismiss()))
    detail.on('unloaded', () => viewer?.dispose())
    try {
      const opened = await entry.open((options) =>
        frame.navigate({ create: () => detail, ...options }),
      )
      if (!opened.navigated || frame.currentPage !== detail) {
        entry.dispose()
        return
      }
      viewer = zoomable(photo, {
        viewport,
        backdrop,
        chrome,
        dismiss: 'vertical',
        async onDismiss(intent) {
          const result = await entry.returnToSource({
            velocity: { x: 0, y: intent.velocity },
          })
          if (!result.navigated) intent.cancel()
        },
      })
    } catch (error) {
      entry.dispose()
      throw error
    } finally {
      opening = false
    }
  })
  return screen
}

Sheet ​

One controller owns the panel, handle, nested ScrollView, keyboard following and return focus. Releasing the root disposes the controller.

iPhone 17 simulator · iOS 26.2. Real XCTest taps and drags; English UI, original speed. Open, resize, add content, type with the real software keyboard, close/reopen and scroll.

The Android demo Activity uses android:windowSoftInputMode="adjustResize", so the keyboard resizes the host rather than panning the whole window. Apply this setting in your app manifest when using this recipe.

Pixel 7 Android API 36 emulator · English UI, original speed. Real ADB taps, typing and swipe: dynamic content, keyboard with adjustResize, close/reopen and nested scroll. No physical-device claim.
ts
import { GridLayout, ScrollView, StackLayout, TextField } from '@nativescript/core'
import {
  bottomSheet,
  GestureSurface,
  type BottomSheet,
} from '@rafaelaugusto/jolt/bottom-sheet'
import { button, label, page } from './ui'

export function sheetRecipe() {
  const screen = page(),
    root = new GridLayout(),
    base = new StackLayout()
  screen.content = root
  root.addChild(base)
  base.addChild(label('A complete sheet recipe'))
  let sheet: BottomSheet | undefined
  const open = button('Open sheet', () => sheet?.open('50%'))
  base.addChild(open)
  const backdrop = new GridLayout()
  backdrop.backgroundColor = '#102922'
  const panel = new GestureSurface()
  panel.rows = 'auto,*'
  panel.backgroundColor = '#F4F0E7'
  const handle = label('Drag to resize', 20)
  handle.automationText = 'recipe-sheet-handle'
  const scroll = new ScrollView(),
    content = new StackLayout()
  scroll.row = 1
  scroll.content = content
  scroll.automationText = 'recipe-sheet-scroll'
  content.addChild(
    button('Add content', () => content.addChild(label('One more useful row', 18))),
  )
  const field = new TextField()
  field.hint = 'Write a message'
  field.accessibilityLabel = 'Recipe message'
  field.margin = 16
  content.addChild(field)
  content.addChild(button('Close sheet', () => sheet?.dismiss()))
  for (let i = 1; i <= 16; i++) content.addChild(label(`Scrollable row ${i}`, 18))
  panel.addChild(handle)
  panel.addChild(scroll)
  root.addChild(backdrop)
  root.addChild(panel)
  root.on('loaded', () => {
    sheet ??= bottomSheet(panel, {
      handle,
      scrollView: scroll,
      backdrop,
      snap: ['content', '50%', '90%'],
      keyboard: 'follow',
      returnFocus: open,
    })
  })
  root.on('unloaded', () => {
    sheet?.dispose()
    sheet = undefined
  })
  return screen
}

List ​

Create one animateItems binding per loaded list; release it on unload. itemLoading updates recycled labels from the current data. Replacing items does not require another binding.

iPhone 17 simulator · iOS 26.2. Real XCTest taps and drags; English UI, original speed. Scroll a virtualized Core ListView and replace its data twice.

For the pinned Core 9.1.3 iOS runtime, this recipe disables UIKit cell prefetching: a prefetched cell could lose its content before display. Fixed row heights and matching estimates keep layout predictable. This is a scoped recipe setting, not a global ListView modification.

ts
import { GridLayout, Label, ListView } from '@nativescript/core'
import { animateItems } from '@rafaelaugusto/jolt'
import { button, label, page } from './ui'

export function listRecipe() {
  const screen = page(),
    root = new GridLayout(),
    list = new ListView()
  root.rows = 'auto,auto,*'
  screen.content = root
  root.addChild(label('A complete list recipe'))
  let generation = 0
  const rows = () =>
    Array.from({ length: 100 }, (_, i) => ({
      title: `Collection ${generation} · item ${i + 1}`,
    }))
  list.items = rows()
  list.row = 2
  list.rowHeight = 68
  list.iosEstimatedRowHeight = 68
  list.automationText = 'recipe-list'
  list.on('itemLoading', (event) => {
    const row = (event.view as GridLayout) ?? new GridLayout()
    row.height = 68
    if (!row.getChildrenCount()) row.addChild(label('', 18))
    ;(row.getChildAt(0) as Label).text = (list.items as { title: string }[])[
      event.index
    ]!.title
    event.view = row
  })
  const replace = button('Replace items', () => {
    generation++
    list.items = rows()
  })
  replace.row = 1
  root.addChild(replace)
  root.addChild(list)
  let unlink: (() => void) | undefined
  list.on('loaded', () => {
    // Core 9.1.3 can detach a prefetched UIKit cell before it is displayed.
    if (__IOS__) list.ios.prefetchingEnabled = false
    unlink?.()
    unlink = animateItems(list, 'fadeIn', { once: true, duration: 350 })
  })
  list.on('unloaded', () => {
    unlink?.()
    unlink = undefined
  })
  return screen
}

Shared UI setup ​

ts
import { Button, Color, Label, Page } from '@nativescript/core'

export function page() {
  const view = new Page()
  view.actionBarHidden = true
  view.backgroundColor = new Color('#F4F0E7')
  return view
}
export function label(text: string, size = 20) {
  const view = new Label()
  Object.assign(view, {
    text,
    fontSize: size,
    color: '#193D34',
    textWrap: true,
    margin: 16,
  })
  return view
}
export function button(text: string, action: () => void) {
  const view = new Button()
  Object.assign(view, {
    text,
    accessibilityLabel: text,
    height: 50,
    margin: 8,
    color: '#193D34',
    backgroundColor: '#D6E7B5',
  })
  view.on('tap', action)
  return view
}

Released under the MIT License.