Skip to content

完整原生示例 ​

这些可运行的 Core NativeScript 页面包括视图创建和资源清理,仅使用公开 API,不依赖框架。Vue 预览只负责挂载 Frame。以下代码直接来自演示应用编译的源文件。

使用 --recipe=gallery、--recipe=sheet 或 --recipe=lists 启动已编译演示。Android 使用对应的 recipe Intent 字符串。支持 Core ListView;其他路由和虚拟列表需要自己的目标解析器。录像不代表物理设备性能。

图库 ​

使用稳定键,在返回时查找当前列表单元格。通过各视图的 src 加载图片;在回收单元格之间共享可变 ImageSource 包装对象可能清空其他视图的像素。Frame 返回回调应用 Jolt 提供的导航选项。查看器保持关闭姿态,直到导航接受或取消。

iPhone 17 模拟器 · iOS 26.2。实际 XCTest 点击和拖动,英文界面,原速录像。缩放、平移、取消关闭、将选中项目移至末尾、按键返回缩略图并再次拖动关闭。
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、键盘跟随及焦点恢复。根视图卸载时释放控制器。

iPhone 17 模拟器 · iOS 26.2。实际 XCTest 点击和拖动,英文界面,原速录像。打开、调整大小、添加内容、使用实际软件键盘输入、关闭/重新打开及滚动。

Android 示例 Activity 使用 android:windowSoftInputMode="adjustResize",使键盘调整宿主尺寸而不平移整个窗口。使用本示例时请在应用清单中设置该选项。

Pixel 7 Android API 36 模拟器 · 英文界面、原速。真实 ADB 点击、输入和滑动展示动态内容、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 根据当前数据更新回收标签,更换数据不创建新绑定。

iPhone 17 模拟器 · iOS 26.2。实际 XCTest 点击和拖动,英文界面,原速录像。滚动虚拟化 Core ListView,并两次替换数据。

在固定使用的 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
}

基于 MIT 许可证发布。