返回博客首页
← 所有文章

这应该是一个插件,还是核心的一部分?(第三部分) - 💥 神奇的交互,蝙蝠侠!

2022年1月11日 — 作者:Nathan Walker

这是关于创建使用原生 iOS 和 Android 视图的 NativeScript 插件系列的第 3 部分。它建立在第 1 部分和第 2 部分中学到的知识之上

在继续我们对 NativeScript 的乐趣探索中,让我们深入研究如何通过微妙的动画和效果来改善用户体验。

特别是,我们将创建轻松添加触摸动画的功能(特别是触摸按下和抬起交互),并讨论是否需要插件,或者向 NativeScript 提交拉取请求是否是一个更好的选择。以下是我们将实现的目标的概览,并使控制此行为变得容易(每个视图或甚至通过一些设置自动为任何具有点击绑定的视图)

丰富的交互体验

有很多方法可以通过触摸交互使用 NativeScript 启用动画。例如,让我们简要讨论 3 种流行的方法

A. 连接 tap 事件以触发被点击视图上的动画。

B. 连接一些其他 gestures 以在触发这些手势的视图上排队动画。

C. 连接 loaded 事件以获取视图实例并手动配置原生手势。

信不信由你,选项 C 是唯一一个能给你带来最大灵活性的选项,但它也需要付出代码开销的代价,而这并不是大多数人认为容易的事情。

对于选项 Atap 绑定仅在触摸按下抬起发生之后触发。这限制很大,因为我们通常希望在手指触摸屏幕时立即触发动画,更不用说用户会期望任何触摸按下时的动画保持该状态,直到他们抬起手指离开屏幕。tap 绑定不适合这种情况。

<Button tap="{{ tapMe }}" />
export function tapMe() {
  // only fires after user lifts their finger off the button
}

对于选项 B,@nativescript/core 提供了许多有用的手势作为便利,例如 doubleTaplongPressswipepanpinchrotationtouch。虽然这些可能适合连接应用逻辑和其他行为(包括一些动画),但你无法轻松自定义某些提供的手势处理的设置,以达到你可能需要的程度。

<Button longPress="{{ longPressMe }}" />
export function longPressMe() {
  // only fires after the default 500ms of touch down (typical default on iOS and Android)
  // by default, this doesn't provide enough granular control of the precise touch we need
  // furthermore, this does not provide us a way to customize the gesture settings
}

对于选项 C,你可以完全控制原生视图实例,做任何你想做的事情,但是它是完全手动的,并且需要大量的样板代码来构建每个场景。

<Button loaded="{{ loadedMe }}" />
export function loadedMe(args) {
  // grab the native instance and manually configure anything your heart desires
  const view = args.object
  if (view.ios) {
    // do anything you'd like natively. e.g. implementing UIGestureRecognizer's
    // likely result in fair bit of setup
  } else if (view.android) {
    // do anything you'd like natively, e.g. implementing android.view.GestureDetector's
    // likely result in fair bit of setup
  }
}

这很灵活,我个人也最常使用这种方法;但是,对于 iOS,你必须考虑实例是否仅基于 UIView,或者是否使用 UIControl API 进行增强,因为你需要针对两者以不同的方式处理手势。

在构建任何应用时,具备执行这些操作的技能始终是有益的;但是,无论你是初学者还是拥有 20 年经验的专业人士,每个人都希望高效地构建事物,因此我们需要一种更好的方法。

让我们专注于触摸按下/抬起行为,对于用户可能触摸按下并在稍后某个时间抬起手指(触摸抬起)的屏幕上的任何视觉元素。最常见的情况当然是任何“可点击”的视觉元素,但让我们不要限制我们的方法,以确保这对于可能甚至没有显式 tap 绑定的元素也很有用。

是插件还是非插件,这是一个问题

我们可能可以构建一个插件来提供此行为,但让我们首先考虑一些事情。

讨论实现思路

在考虑增强任何视图级别细节时,首先确定你希望如何使用它总是一个好主意,因为在这样做的过程中,它通常可以帮助定义在尝试编写新功能之前需要了解的各种情况。

注意:如果你有一些有趣的想法,并希望征求社区关于采取哪些方向的反馈,我们也强烈建议撰写 RFC - 征求意见 讨论。你可以在此处阅读有关 NativeScript RFC 流程 的更多信息。

💡 如果我们能够简单地声明视图的行为方式将会非常不错

<Button touchAnimation="{{ touchAnimation }}" />

声明式有助于很多事情

  • 它表达意图,没有额外的噪音
  • 它一致且易于查找,以便在整个代码库中查找具有此行为的元素
  • 它隔离行为并使我们能够根据需要限制或扩展具有该隔离声明行为的功能
touchAnimation = {
  down: {
    scale: { x: 0.95, y: 0.95 },
    backgroundColor: new Color('yellow'),
    duration: 250,
    curve: CoreTypes.AnimationCurve.easeInOut,
  },
  up: {
    scale: { x: 1, y: 1 },
    backgroundColor: new Color('#63cdff'),
    duration: 250,
    curve: CoreTypes.AnimationCurve.easeInOut,
  },
}

用意图和控制来表达这一点再清楚不过了。我们声明 Button 有一个定义的 touchAnimation,它绑定到一个定义,使我们能够控制触摸按下和抬起状态的动画。

但是,让我们不要停留在第一个想法上,因为当我们当然可以拥有它时,我们喜欢多功能性。除了表达 NativeScript 动画 API(方便、简单易用)之外,我们还希望定义纯原生动画,如 iOS UIView 动画 或甚至 Android 动态弹簧物理动画。因此,提供传递 View 实例以进行超级控制的功能也很好(因为毕竟,我们正在使用我们开发人员工具箱中最通用的工具,NativeScript)

touchAnimation = {
  down(view: View) {
    if (global.isIOS) {
      UIView.animateWithDurationAnimations(0.25, () => {
        view.ios.transform = CGAffineTransformMakeScale(0.95, 0.95)
      })
    } else if (global.isAndroid) {
      const lib = androidx.dynamicanimation.animation
      const spring = new lib.SpringForce(0.95)
        .setDampingRatio(lib.SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
        .setStiffness(lib.SpringForce.STIFFNESS_MEDIUM)
      let animation = new lib.SpringAnimation(
        view.android,
        lib.DynamicAnimation().SCALE_X,
        float(0.95)
      )
      animation.setSpring(spring).setStartVelocity(0.7).setStartValue(1.0)
      animation.start()
      animation = new lib.SpringAnimation(
        view.android,
        lib.DynamicAnimation().SCALE_Y,
        float(0.95)
      )
      animation.setSpring(spring).setStartVelocity(0.7).setStartValue(1.0)
      animation.start()
    }
  },
  up(view: View) {
    if (global.isIOS) {
      UIView.animateWithDurationAnimations(0.25, () => {
        view.ios.transform = CGAffineTransformIdentity
      })
    } else if (global.isAndroid) {
      const lib = androidx.dynamicanimation.animation
      const spring = new lib.SpringForce(1)
        .setDampingRatio(lib.SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
        .setStiffness(lib.SpringForce.STIFFNESS_MEDIUM)
      let animation = new lib.SpringAnimation(
        view.android,
        lib.DynamicAnimation().SCALE_X,
        float(1)
      )
      animation.setSpring(spring).setStartVelocity(0.7).setStartValue(0.95)
      animation.start()
      animation = new lib.SpringAnimation(
        view.android,
        lib.DynamicAnimation().SCALE_Y,
        float(1)
      )
      animation.setSpring(spring).setStartVelocity(0.7).setStartValue(0.95)
      animation.start()
    }
  },
}

现在这很强大 👊 而且非常有趣。但是,你可能已经注意到,定义一些原生 API 动画有时可能很冗长。想象一下为每个需要它的视图定义这些 🤔 - 你可以重用该单个定义并在整个声明式 UI 中绑定它,以使用交互式动画丰富你的视图,但同样,让我们不要停留在第二个想法上。

注意:🤯 我们也可以通过 CSS 属性启用,但让我们将 CSS 实现细节留到以后的文章中,因为我们在这里要做的将涵盖即使在将来引入新的 CSS 属性以启用此功能所需的关键细节。使用 NativeScript,首先关注功能实现始终是最好的,因为稍后通过 CSS 启用只是你随时可以执行的语法糖。

如果我们有一个自动的主开关呢?

由于我们专注于触摸按下/抬起,因此可以说,丰富应用交互性和用户体验并提供即时触摸反馈的最常见方法是将动画应用于屏幕上的任何“可点击”元素。此外,“可点击”元素通常会获得最多的用户“触摸”。

如果我们能够启用一个 boolean,它将自动检测任何具有 tap 绑定的 UI 元素,并使用我们定义一次的一致动画自动配置正确触摸/抬起手势,那就太好了。在应用中的所有“可点击”表面提供一致的触摸反馈当然会让它更加精致。

我们需要类似于 TouchManager 的东西来启用一些这些便捷功能,例如 TouchManager.enableGlobalTapAnimations = true,但在我们自以为是之前,让我们首先从一个新的自定义属性开始探索我们的起点,以及我们是否可以通过插件单独添加这样的属性。

通过插件添加新的视图属性?

如果我们希望应用中的所有 View 元素都具有一个新的属性,例如 touchAnimation,我们可以通过在层次结构中的基础视图类(例如 ViewBase)中注册一个新的 Property 来实现。

import { Property, ViewBase } from '@nativescript/core'

const touchAnimationProperty = new Property<ViewBase, any>({
  name: 'touchAnimation',
  valueChanged(view, oldValue, newValue) {
    (<any>view).touchAnimation = newValue
  },
})
touchAnimationProperty.register(ViewBase)

// bootstrap the app...

这将有效,但需要用户在应用引导之前导入我们的插件,这不是什么大问题,所以是可以做到的。另外请注意 any 转换 - 我们需要做很多次,因为 View 类不会正式支持这样的属性。

鉴于这将为我们提供一个新的属性,那么我们如何将这些动画应用于声明它的特定视图的触摸按下/抬起手势呢?

然后我们可以修改 ViewBase 的原型以覆盖 onLoaded,以检查视图实例是否声明该属性,以便在一个唯一的位置进一步连接所需的手势,该位置保证拥有我们执行此操作所需的原生视图实例

// ...
touchAnimationProperty.register(ViewBase)

const origOnLoaded = ViewBase.prototype.onLoaded
ViewBase.prototype.onLoaded = function (...args) {
  // wire up our gestures here...
  if (!this.isLoaded) {
    if ((<any>this).touchAnimation) {
      // add gestures as defined by touchAnimation
      if (this.ios) {
        // wire up native iOS gestures
      } else if (this.android) {
        // wire up native Android gestures
      }
    }
  }

  origOnLoaded.call(this, ...args)
}

// bootstrap the app...

我们甚至如何添加潜在的功能来自动检测视图是否具有 tap 绑定以及此新的 TouchManager 是否启用了 enableGlobalTapAnimations 以考虑这种情况?

// ...
touchAnimationProperty.register(ViewBase)

const origOnLoaded = ViewBase.prototype.onLoaded
ViewBase.prototype.onLoaded = function (...args) {
  // wire up our gestures here...
  if (!this.isLoaded) {
    const enableTapAnimations =
      TouchManager.enableGlobalTapAnimations &&
      (this.hasListeners('tap') ||
        this.hasListeners('tapChange') ||
        this.getGestureObservers(GestureTypes.tap))
    if ((<any>this).touchAnimation || enableTapAnimations) {
      // add gestures as defined by touchAnimation or auto detected tap binding so add them
    }
  }

  origOnLoaded.call(this, ...args)
}

// bootstrap the app...

这将有效,即使修改任何类的原型可能会在将来导致麻烦,具体取决于该特定类的底层行为是否发生变化。因此,以这种方式进行操作会对未来维护采用这种方式的插件带来一些风险。

向 @nativescript/core 贡献拉取请求怎么样?

任何人在他们认为某个功能可能在他们的情况下帮助他们的任何时候都可以做到这一点,同时提供其他人也可能觉得有用的功能。在核心代码库中工作并意识到无限可能触手可及也是非常有趣的。

注意:如果你有一些有趣的想法,并希望征求社区关于采取哪些方向的反馈,我们也强烈建议撰写 RFC - 征求意见 讨论。你可以在此处阅读有关 NativeScript RFC 流程 的更多信息。

Fork NativeScript 核心仓库并提交拉取请求

让我们创建我们自己的 核心仓库的 fork 并提交拉取请求。

git clone https://github.com/NathanWalker/NativeScript.git
cd NativeScript
git checkout -b feat/touch-manager

npm run setup

我们现在准备将这些功能直接添加到核心。

添加新的 TouchManager

由于这添加了新的触摸相关功能,我们可以将新的 TouchManager 添加到...

  • packages/core/ui/gestures/touch-manager.ts:
export class TouchManager {
  static enableGlobalTapAnimations: boolean
  // add more features as needed...
}

核心中的所有各种组织的功能都维护自己的 index.d.ts,因为它提供了各种自定义文档和任何其他跨平台类型处理(如果需要),因此让我们确保它在那里导出,以便 TypeScript 可以获取它

  • packages/core/ui/gestures/index.d.ts:
export * from './touch-manager'

我们还希望确保其实现可从 iOS 和 Android 运行时上下文中访问,以便我们还可以确保它从手势的通用文件中导出

  • packages/core/ui/gestures/gestures-common.ts:
export * from './touch-manager'

最后,核心的每个部分都有自己的 index.ts,它根据需要公开整个文件夹或特定符号,因此我们可以确保 TouchManager 可以通过添加其符号导出从 @nativescript/core 访问

  • packages/core/ui/index.ts:
export {
  GesturesObserver,
  TouchAction,
  GestureTypes,
  GestureStateTypes,
  SwipeDirection,
  GestureEvents,
  TouchManager,
} from './gestures' // <-- added TouchManager

我们现在有一个主开关,可以全局打开我们希望为 UI 中所有“可点击”元素自动启用的任何动画。

TouchManager.enableGlobalTapAnimations = true;

酷 😎

添加新的 touchAnimation 属性

我们现在可以将新的 touchAnimation 属性直接添加到 ViewCommon 中,就像我们通过将自身注册到 ViewCommon 的插件那样。我们还可以开始定义我们自己的类型来帮助对所有这些功能进行强类型化。

  • packages/core/ui/core/view/view-common.ts:
export type TouchAnimationFn = (view: View) => void
export type TouchAnimationOptions = {
  up?: TouchAnimationFn | AnimationDefinition
  down?: TouchAnimationFn | AnimationDefinition
}
export abstract class ViewCommon extends ViewBase implements ViewDefinition {
  // ...
  public touchAnimation: boolean | TouchAnimationOptions
  public ignoreTouchAnimation: boolean
  // ...
}
const touchAnimationProperty = new Property<
  ViewCommon,
  boolean | TouchAnimationOptions
>({
  name: 'touchAnimation',
  valueChanged(view, oldValue, newValue) {
    view.touchAnimation = newValue
  },
  valueConverter(value) {
    if (isObject(value)) {
      return <TouchAnimationOptions>value
    } else {
      return booleanConverter(value)
    }
  },
})
touchAnimationProperty.register(ViewCommon)

在处理核心中的修改时,你可以开始使用存储库中存在的 apps/toolbox 应用来尝试一些操作,该应用就是为此目的而存在的。我们可以使用以下选项启动工具箱

$ npm start
# type `toolbox.ios` or `toolbox.android` and hit ENTER

你可以为任何你想要开发的新功能创建页面,或者只是在 apps/toolbox/src/pages 中进行实验。

在处理事情时,你经常会找到优化实现以及改进最初想法的方法。

这是我为了让每个人都能从中受益而提交的pull request

新发现的功能和易用性

通过我们在这里所做的一切,我们现在能够使用 2 个设置将一致的触摸按下/抬起动画应用于整个应用程序中的任何“可点击”内容 - **是的,整个应用程序**(延迟加载、导航到、弹出或你能想到的任何内容)。

import { TouchManager } from '@nativescript/core'

TouchManager.enableGlobalTapAnimations = true
TouchManager.animations = {
  down: {
    scale: { x: 0.95, y: 0.95 },
    duration: 200,
    curve: CoreTypes.AnimationCurve.easeInOut,
  },
  up: {
    scale: { x: 1, y: 1 },
    duration: 200,
    curve: CoreTypes.AnimationCurve.easeInOut,
  },
}

// bootstrap the app,
// and without thinking further, all view's with 'tap' bindings will auto-animate touch down/up

如果你有一些需要忽略的“可点击”视图

<Button text="Global tap animations simply ignored" ignoreTouchAnimation="true"/>

这里是否仍然需要插件?

在上面,我们正在探索一些 Android API 的用法,这些 API 在默认的 Android 应用程序中不包含。

const androidAnimation = androidx.dynamicanimation.animation
const spring = new androidAnimation.SpringForce(0.95)
  .setDampingRatio(androidAnimation.SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
  .setStiffness(androidAnimation.SpringForce.STIFFNESS_MEDIUM)

此外,最好创建一些不错的即插即用动画效果,我们也可以将它们与新的touchAnimation属性一起使用。由于通常不会接受向@nativescript/core提交包含此类第三方库的pull request,因此我们仍然需要一个插件。

我们可以简单地将插件添加到我们在第 1 部分中创建的工作区中,但我总是喜欢查看整个 NativeScript 社区,看看添加这样的插件是否有意义,或者是否可以为一个执行类似操作但可能也需要此功能的插件做出贡献。我经常搜索“nativescript 动画”(或“nativescript 效果”)或浏览 npm 搜索“nativescript”。

事实证明,有一个插件已经提供了一些不错的 NativeScript 动画效果

让我们分叉该仓库,并提供另一个pull request来添加一些额外的效果和功能,使该插件更加完善。

你可以在这里找到我提交的另一个改进该插件的pull request - 这将允许将各种即插即用动画效果定义分配给新的touchAnimation属性。

import { TouchAnimationOptions } from '@nativescript/core'
import { NativeScriptEffects } from 'nativescript-effects'

export class MainViewModel {
  touchAnimation: TouchAnimationOptions = {
    down: NativeScriptEffects.fadeTo(0.7),
    up: NativeScriptEffects.fadeTo(1),
  }
}
<Button text="Tap Me" touchAnimation="{{ touchAnimation }}" />

💥 互动性爆棚!

结合我们上面所做的一切,我们现在有了一种简单易行的方法来为我们的应用程序添加漂亮的互动性 - 并且你也可以这样做 - 这些功能将成为 NativeScript 8.2 版本的一部分!

你可以在这里找到它的实现

哦,还有猜猜看?

这种方法适用于AngularVueReactSvelte以及任何其他版本;比如一些SolidJS怎么样?...无需任何额外操作。