返回博客首页
← 所有文章

使用原生 iOS 和 Android 视图创建 NativeScript 插件(第一部分) - 标签跑马灯!

2021年10月13日 — 作者:Nathan Walker

这是关于创建 NativeScript 插件系列文章的第一部分

围绕 NativeScript 最常被问到的问题之一是如何为数千种平台原生 iOS 和 Android 视图创建自定义视图组件。

让我们概述理解其惊人简单和趣味性的关键基础知识。

事实上,最近的一个客户项目需要在标题上实现跑马灯效果(自动滚动超出设备屏幕宽度的文本),因此这是一个完美的时机,可以概述如何随时创建自定义视图组件并在项目中立即使用它。

custom-view-component-marquee-demo

使用工作区构建

无论何时创建您自己的 NativeScript 插件(*或只是尝试创建新的视图组件*),我们都强烈建议使用此处提供的官方插件种子工作区。它可以快速创建和构建,无需额外麻烦,并且通过自动化迁移为您在将来的维护中提供便利。

使用模板

create-workspace1

您可以直接从仓库中选择“使用此模板”将其复制到您选择的组织中,并为其提供您喜欢的名称。

create-workspace2

现在我们的新工作区已在 GitHub 上,我们可以克隆它开始乐趣。

create-workspace3
git clone https://github.com/nstudio/nativescript-ui-kit.git
Cloning into 'nativescript-ui-kit'...

cd nativescript-ui-kit

设置和配置工作区

  1. 第一步将确保所有依赖项都正确安装,并且在克隆工作区后只需要运行一次。您也可以在任何时候简单地想要清理/重置工作区依赖项时使用它。
npm run setup
  1. 现在让我们将其配置为使用我们喜欢的设置,例如我们希望将这些插件与哪个组织关联。这是@nativescript/camera(绑定到@nativescript npm 组织)与nativescript-camera(未绑定到任何 npm 组织)之间的区别。

注意:不用担心,在配置工作区时将 npm 范围设置为组织不会限制您只能在此工作区中创建绑定到 npm 组织的插件。您可以在每次通过提示添加新插件时做出此选择。

这还将使我们有机会配置我们希望每个包使用的默认 package.json 存储库 URL 和作者详细信息。

npm run config

? What npm scope would you like to use for this workspace?
 nstudio

? What is the git repository address?
 https://github.com/nstudio/nativescript-ui-kit

? What is the author display name?
 nstudio

? What is the author email?
> [email protected]

您的工作区现已配置并准备就绪!

https://github.com/nstudio/nativescript-ui-kit

我们计划向此工作区添加相当多的基于 UI 的插件,因此这将是讨论自定义视图组件创建系列文章的第一部分。

添加 label-marquee 包

让我们添加一个包来开发我们的自定义@nstudio/nativescript-label-marquee视图组件。

npm run add

? What should the new package be named? 
 nativescript-label-marquee

? Should it use the npm scope of the workspace?
 true

"@nstudio/nativescript-label-marquee" created and added to all demo apps.
Ready to develop!

这创建了一个packages/nativescript-label-marquee文件夹,其中包含您需要的自定义 NativeScript 插件的所有基础知识。它还注释了工作区中的所有演示应用程序以使用它,因此试用它既快速又高效。

继续前进 🚀

自定义 NativeScript 视图插件的结构

任何自定义 NativeScript 视图组件只有 2 个**基本**必需方面(以及 2 个*可选*添加)

// 1. (required) extend any NativeScript View
export class CustomView extends View { 

  // 2. (required) Construct and return any platform native view
  createNativeView() {
      // return instance of UIView or android.view.View;
  }

  // 3. (optional) initialize anything
  initNativeView() {

  }

  // 4. (optional) cleanup anything
  disposeNativeView() { 

  }
}
  1. "扩展任何 NativeScript 视图" === **任何** NativeScript 视图,例如这些也都是有效的
export class CustomView extends ContentView { ...

export class CustomView extends Label { ...

export class CustomView extends Button { ...
  1. createNativeView 从字面上创建并返回**任何**平台原生视图,例如这些都是有效的
// iOS
createNativeView() {
  return new WKWebView({
    frame: CGRectZero,
    configuration: configuration,
  });
}
// Android
createNativeView() {
  return new android.webkit.WebView(this._context);
}
  1. initNativeView 初始化任何您想要的内容

  2. disposeNativeView 如果需要,销毁和清理任何内容

准备 nativescript-label-marquee

我们会注意到每个新添加的包都附带了供我们开发的文件

  • common.ts iOS 和 Android 之间的共享通用代码
  • index.android.ts Android 特定代码
  • index.d.ts 用于在项目中轻松使用的类型定义
  • index.ios.ts iOS 特定代码

首先,我们希望我们的自定义视图组件被称为LabelMarquee,以便我们可以在 NativeScript 视图标记中使用它,方法是使用<LabelMarquee></LabelMarquee>。*新添加的包总是以与我们添加的包名称匹配的类名开头。*

由于 Android 和 iOS 实现始终派生自common基类,因此让我们调整common.ts以从此处适当地命名我们的基类

export class NativescriptLabelMarqueeCommon extends Observable {
}

到此

export class LabelMarqueeCommon extends Observable {
}

我们注意到它默认情况下extends Observable。不要与rxjs Observable 混淆,在 NativeScript 层次结构中很常见,所有组件都将派生自@nativescript/core中的 NativeScript Observable。它是在rxjs出现之前很久开发的,因此名称一直保留了下来。它仅仅意味着该类在所有 NativeScript 结构中提供了基本构建块,以提供在属性更改时发出事件并在其自身上发出自定义事件的能力。

由于在这种情况下我们正在创建自定义视图组件,因此我们可以从View扩展,它本身已经继承自Observable。*但是,您可以从@nativescript/core提供的任何内容扩展以创建自定义行为。*

事实上,因为我们本质上是在创建一个具有附加效果的新Label,所以我们可以简单地扩展已经存在的Label,它也扩展自View

import { Label } from '@nativescript/core';

export class LabelMarqueeCommon extends Label {
}

我们现在更新index.ios.tsindex.android.ts以匹配并表示每个平台的实现

import { LabelMarqueeCommon } from './common';

export class LabelMarquee extends LabelMarqueeCommon {

}

这将创建一个新的LabelMarquee视图组件,它只是模仿 NativeScript 中基本Label类的行为。

现在让我们添加自定义视图实现细节。✨

使用第三方库还是直接编写代码?

这通常是我们作为开发人员做出的最艰难的决定。当涉及到自定义 NativeScript 视图组件时,可能性是无限的。这意味着我们在开源中找到的任何现成的组件,我们始终可以选择使用 NativeScript 直接将其编码到我们的插件中,从而消除对第三方的任何依赖。随时可以选择执行其中一项或两项。对我们来说,如果任何第三方开源插件的历史记录很强,我们通常会放心地使用它,因为知道如果将来它不再维护,我们可以在将来选择将其直接编码到插件中。

在这种情况下,iOS 上的标签跑马灯效果已由这位优秀的开源作者提供:https://github.com/cbpowell/MarqueeLabel

对于 Android,事实证明@nativescript/core中已使用的android.widget.TextView提供了实现此效果的选项:https://android-docs.cn/reference/android/widget/TextView#setEllipsize(android.text.TextUtils.TruncateAt) + https://android-docs.cn/reference/android/text/TextUtils.TruncateAt#MARQUEE

因此,我们将以这两者作为我们的指导。

添加 platforms 文件夹以支持第三方依赖项

我们将首先实现 iOS。

对于 iOS,cbpowell/MarqueeLabel 提供了一个CocoaPod,我们可以通过在包中创建platforms/ios/Podfile来包含它,如下所示

  • packages/nativescript-label-marquee/platforms/ios/Podfile
pod 'MarqueeLabel'

每个包含platforms文件夹的 NativeScript 插件都意味着其中将会有一个iosandroid文件夹,通常包含任意数量的平台原生源代码、配置或依赖项,这些依赖项将成为它们添加到其中的 NativeScript 应用程序的一部分。

生成与之配合使用的类型定义

现在我们已经使用第三方依赖项设置了插件,我们可以使用工作区中的演示应用程序来生成 TypeScript 声明(即类型、类型定义等),以针对我们的插件进行强类型检查,使我们的开发变得有趣且有益。

cd apps/demo
ns typings ios

这将创建一个apps/demo/typings文件夹,其中包含演示应用程序中包含的所有原生平台 API 的 TypeScript 声明,其中还包括我们新添加的cbpowell/MarqueeLabel插件!

custom-view-component-marquee-typings

我们可以将这些类型定义移动到我们的插件中以帮助我们开发自定义视图组件。

创建一个packages/nativescript-label-marquee/typings文件夹并将objc!MarqueeLabel.d.ts放在其中

custom-view-component-marquee-typings-in-package

我们现在可以将其添加到包references.d.ts中,以确保我们的 TypeScript 编辑器允许我们直接针对这些类型进行编码。

custom-view-component-marquee-typings-included

注意:您现在可以删除apps/demo/typings文件夹,因为我们不再需要它了。

这允许我们使用cbpowell/MarqueeLabel iOS CocoaPod提供的原生平台 API 进行编码。

custom-view-component-marquee-typings-code

createNativeView

由于我们的LabelMarquee组件应提供具有跑马灯效果的标签实例,因此我们将只返回cbpowell/MarqueeLabel的实例。

import { LabelMarqueeCommon } from './common';

export class LabelMarquee extends LabelMarqueeCommon {
  createNativeView() {
    return MarqueeLabel.alloc().init();
  }
}

对于许多 iOS 类,您通常可以使用标准化的alloc().init()方法链来初始化 Objective C 或 Swift 类的正确实例。各种插件可能会提供不同的初始化程序,您将在每个插件的生成类型定义中找到这些初始化程序,这就是为什么拥有类型定义可以让自定义视图组件开发非常好的原因。在 Objective C 中,allocallocate的缩写,在历史上指的是类的内存分配实例的静态创建,您可以随后在该实例上调用方法,例如init(默认初始化程序)。

initNativeView

接下来,我们可以初始化自定义视图上的任何默认设置。cbpowell/MarqueeLabel支持fadeLength,它确定标签在超出可见屏幕宽度的那部分文本上应淡出的距离。它还支持scrollDuration,它确定滚动动画的速度。

我们将将其设置为一些合理的默认值。

import { LabelMarqueeCommon } from './common';

export class LabelMarquee extends LabelMarqueeCommon {
  createNativeView() {
    return MarqueeLabel.alloc().init();
  }

  initNativeView() {
    const nativeView = <MarqueeLabel>this.nativeView;
    nativeView.fadeLength = 10;
    nativeView.scrollDuration = 8;
  }
}

在这里,我们使用默认的 NativeScript getter this.nativeView,它表示使用createNativeView创建的原生视图的实例。我们将其转换为正确的原生类型MarqueeLabel,这使我们在代码编辑器中获得智能提示,并在设置其各种属性时进行强类型检查,以便于将来维护。

我们现在将跳过处理disposeNativeView,因为目前似乎没有我们需要在销毁时清理的内容,但是如果需要,我们以后可以添加它。

试用新的自定义视图组件!

信不信由你,创建针对目标平台的自定义 NativeScript 视图组件就是这么简单(在确认 iOS 正常工作后,我们将添加 Android 实现)。

我们准备在演示应用程序中试用它。

当我们使用npm run add添加上面的包时,它实际上创建了演示页面,注释了所有演示应用程序以使用它,并在工作区的所有演示应用程序中添加了对该包的支持共享代码。我们可以将我们的视图组件放在其中一个页面中以验证它是否有效。

您选择使用哪种风格来演示您的自定义插件取决于您自己;为了简洁起见,我们只展示将其添加到普通演示中(apps/demo)。

我们可以向Page元素添加命名空间

  1. Page添加命名空间:xmlns:lm="@nstudio/nativescript-label-marquee"
  2. 使用新的自定义视图组件:<lm:LabelMarquee text="Lorem Ipsum; this is a long string of text that will animate because it's longer than the width of the view."></lm:LabelMarquee>
  • apps/demo/src/plugin-demos/nativescript-label-marquee.xml:
<Page xmlns="http://schemas.nativescript.org/tns.xsd"
  navigatingTo="navigatingTo" class="page"
  xmlns:lm="@nstudio/nativescript-label-marquee">
  <Page.actionBar>
    <ActionBar title="LabelMarquee" class="action-bar">
    </ActionBar>
  </Page.actionBar>
  <StackLayout class="p-20">
    <ScrollView class="h-full">
      <StackLayout>
        <lm:LabelMarquee text="Lorem Ipsum; this is a long string of text that will animate because it's longer than the width of the view."></lm:LabelMarquee>
      </StackLayout>
    </ScrollView>
  </StackLayout>
</Page>

运行iOS演示

我们可以通过两种方式运行我们的演示应用程序

  1. npm start显示菜单。然后输入demo.ios并按ENTER。
  2. 我们也可以通过调用以下命令直接运行它:nx run demo:ios
custom-view-component-marquee-demo-ios

非常棒 😊

添加更多选项

cbpowell/MarqueeLabel插件公开了许多巧妙的选项。让我们通过用户可以在视图组件上设置的属性来添加对其中一些属性的支持。

首先,我们可以添加属性以允许开发人员设置自己的fadeLengthscrollDuration值来覆盖我们使用initNativeView设置的默认值。

在不知道哪些属性将完全支持Android的情况下,我们可以为iOS和Android创建属性,并假设当我们很快开始处理Android实现时,我们可能能够在Android实现中支持相同的属性。

让我们将属性定义添加到common.ts中,以便index.ios.tsindex.android.ts都可以在可能的情况下实现它们。

这个Property是什么东西?

为了定义我们希望我们的自定义视图组件公开的属性,以便开发人员可以动态更改其视觉特性,我们可以使用一个良好的旧类属性设置器。但是,NativeScript在此提供了一些超越此范围的帮助,这甚至更好。

@nativescript/core Property是一个类,以及其他相关的类,例如CssProperty,它们都用于定义属性。您可以简单地在您的类中使用设置器,但是Property类(及其关联)提供了内置在其中的几个方便的实用程序,这些实用程序减少了许多在各种用例下的担忧,例如当属性应该根据底层原生类是否可用(已初始化)并准备好设置属性来设置其值时。它还通过NativeScript的Observable烘焙自动事件发射,这有助于在属性更改时通知侦听器。

  • common.ts
// 1. import the Property class
import { Label, Property } from '@nativescript/core'

export class LabelMarqueeCommon extends Label {}

// 2. define a new property
// "name" Will become the view attribute which developers can use
export const fadeLengthProperty = new Property<LabelMarqueeCommon, number>({
  name: 'fadeLength',
})

// 3. register the property with the view component class
fadeLengthProperty.register(LabelMarqueeCommon)

这定义了一个新的属性fadeLength,我们可以用它来修改平台特定的行为,当开发人员想要自定义视图组件的工作方式时。

让我们在iOS实现中使用它

  • index.ios.ts
import { LabelMarqueeCommon, fadeLengthProperty } from './common';

export class LabelMarquee extends LabelMarqueeCommon {

  createNativeView() {
    return MarqueeLabel.alloc().init();
  }

  initNativeView() {
    const nativeView = <MarqueeLabel>this.nativeView;
    nativeView.fadeLength = 10;
    nativeView.scrollDuration = 8;
  }

  [fadeLengthProperty.setNative](value: number) {
    (<MarqueeLabel>this.nativeView).fadeLength = value;
  }
}

我们导入fadeLengthProperty并使用其setNative api定义一个新属性,该api创建了一个由属性名称绑定的超级增强的设置器。当开发人员使用这些属性动态更改这些属性时,这将处理在适当的时间在我们视图组件上设置原生属性的所有特定行为。包括属性需要以特定方式逻辑处理的任何内容的通用解析逻辑(稍后详细介绍)。

为了避免类转换(<MarqueeLabel>this.nativeView)进行强类型检查,我们将使用NativeScript中烘焙到所有视图组件中的通用方法。使用iosandroid获取器来简化我们的开发和未来的维护。这些获取器在所有NativeScript视图组件的底层可用。它们将始终返回该特定平台的具体原生类实现。我们可以在每个平台实现源文件中覆盖它们以返回平台特定的类,这极大地提高了我们的生活质量

import { LabelMarqueeCommon, fadeLengthProperty } from './common';

export class LabelMarquee extends LabelMarqueeCommon {

  // @ts-ignore
  get ios(): MarqueeLabel {
    return this.nativeView;
  }

  createNativeView() {
    return MarqueeLabel.alloc().init();
  }

  initNativeView() {
    this.ios.fadeLength = 10;
    this.ios.scrollDuration = 8;
  }

  [fadeLengthProperty.setNative](value: number) {
    this.ios.fadeLength = value;
  }
}

可爱 😍

我们现在有了一种动态更改fadeLength的方法

<LabelMarquee
  text="Lorem Ipsum; this is a long string of text that will animate because it's longer than the width of the view."
  fadeLength="20"></LabelMarquee>

添加Android实现

Android的Label@nativescript/core中已经是一个android.widget.TextView,它原生支持跑马灯效果,所以让我们用我们的插件启用它并让开发人员控制何时启用它。

由于我们的插件已经扩展了Label,所以我们在这里几乎不需要做任何事情。

  • index.android.ts
import { LabelMarqueeCommon } from './common';

export class LabelMarquee extends LabelMarqueeCommon {

  // @ts-ignore
  get android(): android.widget.TextView {
    return this.nativeView;
  }

  initNativeView() {
    this.android.setSingleLine(true);
    this.android.setEllipsize(android.text.TextUtils.TruncateAt.MARQUEE);
    this.android.setMarqueeRepeatLimit(-1); // -1 is infinite
    this.android.setSelected(true);
  }
}

有许多文章介绍如何在Android上启用跑马灯效果;以上内容来自这里

让我们添加另一个属性,使开发人员能够在需要时关闭滚动文本。我们将新属性命名为labelize并将其添加到common中,以便我们可以在Android和iOS上实现它

  • common.ts
import { Label, Property, booleanConverter } from '@nativescript/core';

export class LabelMarqueeCommon extends Label {
}

export const fadeLengthProperty = new Property<LabelMarqueeCommon, number>({
  name: 'fadeLength'
});
fadeLengthProperty.register(LabelMarqueeCommon);

export const labelizeProperty = new Property<LabelMarqueeCommon, boolean>({
  name: 'labelize',
  defaultValue: false,
  valueConverter: booleanConverter,
});
labelizeProperty.register(LabelMarqueeCommon);

在这里,我们看到了Property类的更多功能,它提供了一种无缝且可重用的方法,可以将值解析(从视图标记值输入到实际类属性值设置)直接烘焙到属性定义本身中。由于我们将labelizeProperty定义为boolean,并且视图将值作为string发送(因为视图组件标记只是一个字符串!),Property类为我们提供了一个api,我们可以在其中定义各种值解析逻辑。@nativescript/core甚至提供了数十个值转换器,其中一个是booleanConverter,它使我们能够通过属性设置器本身来加强组件完整性,确保传入的任何值都转换为我们的平台实现所需的有效boolean值。

现在让我们用它来公开另一个开发人员可以用来自定义我们自定义视图组件行为的有用属性。

  • index.android.ts
import { LabelMarqueeCommon, labelizeProperty } from './common';

export class LabelMarquee extends LabelMarqueeCommon {

  // @ts-ignore
  get android(): android.widget.TextView {
    return this.nativeView;
  }

  initNativeView() {
    this.android.setSingleLine(true);
    this.android.setEllipsize(android.text.TextUtils.TruncateAt.MARQUEE);
    this.android.setMarqueeRepeatLimit(-1); // -1 is infinite
    this.android.setSelected(true); // starts the scrolling effect
  }

  [labelizeProperty.setNative](value: boolean) {
    this.android.setSelected(!value);
    const ellipsis = value ? android.text.TextUtils.TruncateAt.END : android.text.TextUtils.TruncateAt.MARQUEE;
    this.android.setEllipsize(ellipsis);
  }
}

至此,我们也完成了Android的实现。我们可以继续添加更多属性和功能。例如,我们可能会在未来的帖子中重新审视一些其他想法来扩展此内容

在我们得意忘形之前,让我们使新的labelize属性也适用于iOS。

同样在iOS中重用新的labelize属性

由于我们在common.ts中创建了labelizeProperty,因此我们也可以简单地使用它添加到我们的iOS实现中。

  • index.ios.ts
import { LabelMarqueeCommon, fadeLengthProperty, labelizeProperty } from './common';

export class LabelMarquee extends LabelMarqueeCommon {

  // @ts-ignore
  get ios(): MarqueeLabel {
    return this.nativeView;
  }

  createNativeView() {
    return MarqueeLabel.alloc().init();
  }

  initNativeView() {
    this.ios.fadeLength = 10;
    this.ios.scrollDuration = 8;
  }

  [fadeLengthProperty.setNative](value: number) {
    this.ios.fadeLength = value;
  }

  [labelizeProperty.setNative](value: boolean) {
    this.ios.labelize = value;
  }
}

我们现在有了一种方法可以使用相同API在iOS和Android上切换文本跑马灯滚动效果的打开和关闭

<LabelMarquee
  text="Lorem Ipsum; this is a long string of text that will animate because it's longer than the width of the view."
  [labelize]="labelizeEnabled"></LabelMarquee>

立即在您的项目中使用!

所以您可能会问自己,这很好,但是创建这个自定义视图组件的全部目的是因为我们现在需要它在一个实际的实时项目中!

好吧,是的,所以让我们打包并使用它 📦

此时,您可以将其发布到npm或将其简单地打包到.tgz中。这两种方法都是快速有效的方法,可以立即在您的项目中使用此新的视图组件。我将向您展示如何执行这两者。

打包它

所有构建都将输出到dist/packages/{package-name}

npm start
> type "labelmarquee.build"
// then hit ENTER to kick off the plugin build

// This is a convenient way to see all commands in your workspace
// Typing any first few characters of something you're looking for will narrow it down

您会注意到,无论何时在npm start菜单中的任何命令上按ENTER键,都会输出一个绿色命令,该命令是实际执行的Nx命令。如果您发现使用npm start菜单没有必要,则可以随时使用该直接命令。例如,nx run nativescript-label-marquee:build.all与我们刚刚使用npm start交互式菜单执行的操作完全相同。

您现在可以导航到输出并将其打包。

cd dist/packages/nativescript-label-marquee
npm pack

这将创建一个nstudio-nativescript-label-marquee-1.0.0.tgz文件,您可以将其简单地放入任何项目中并在其package.json中引用如下

"@nstudio/nativescript-label-marquee": "file:nstudio-nativescript-label-marquee-1.0.0.tgz"

现在只需ns clean并运行您的项目即可享受使用您的新视图组件。

发布到npm

如果您已经测试了您的插件并相信它已准备好投入使用,那么您可以将其发布到npm。

npm run publish-packages

? Which packages 📦 would you like to publish?
 nativescript-label-marquee

? What 🆕 version would you like to publish? 
 1.0.0

? Sanity check 🧠 Are you sure you want to publish? (y/N) 
 y

然后您的软件包将发布到npm。当然,您必须登录到具有发布权限的有效npm帐户。

publish-packages工作流程非常方便快捷。

  • 当提示您使用Which packages 📦时,您可以选择在不输入任何名称的情况下按Enter键,它将依次发布工作区内的所有软件包,从而使管理整个插件套件变得轻松。

  • 当提示您使用What 🆕 version时,您可以显式键入要使用的版本字符串,或者只需按Enter键即可使其自动增加补丁版本。您还可以使用alphabetarc版本字符串,工作流程将检测到它们并在npm上正确地自动标记它们。例如,1.0.0-alpha.0将在npm上将其标记为alpha

  • 当提示您使用Sanity check 🧠时,嗯……检查一下您的头脑。

享受视图

啊,现在在实时项目中看到它真是太好了……

custom-view-component-marquee-sweet