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.
Gallery
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.
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.
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.
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.
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.
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
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
}