完整原生示例
这些可运行的 Core NativeScript 页面包括视图创建和资源清理,仅使用公开 API,不依赖框架。Vue 预览只负责挂载 Frame。以下代码直接来自演示应用编译的源文件。
使用 --recipe=gallery、--recipe=sheet 或 --recipe=lists 启动已编译演示。Android 使用对应的 recipe Intent 字符串。支持 Core ListView;其他路由和虚拟列表需要自己的目标解析器。录像不代表物理设备性能。
图库
使用稳定键,在返回时查找当前列表单元格。通过各视图的 src 加载图片;在回收单元格之间共享可变 ImageSource 包装对象可能清空其他视图的像素。Frame 返回回调应用 Jolt 提供的导航选项。查看器保持关闭姿态,直到导航接受或取消。
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
}底部面板
一个控制器管理面板、拖动手柄、嵌套 ScrollView、键盘跟随及焦点恢复。根视图卸载时释放控制器。
Android 示例 Activity 使用 android:windowSoftInputMode="adjustResize",使键盘调整宿主尺寸而不平移整个窗口。使用本示例时请在应用清单中设置该选项。
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
}列表
每个已加载列表只创建一个 animateItems 绑定,并在卸载时释放。itemLoading 根据当前数据更新回收标签,更换数据不创建新绑定。
在固定使用的 iOS Core 9.1.3 中,此示例关闭 UIKit 单元格预取,避免预取的单元格在显示前丢失内容。固定行高与匹配的估算值使布局可预测。该设置仅用于此示例,不会全局修改 ListView。
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
}共享 UI
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
}