This is Part 3 in a series about creating NativeScript plugins that use native iOS & Android views. It builds upon what was learned in Part 1 and 2:
Continuing our quest for fun with NativeScript let's dig into improving user experiences with better touch feedback via subtle animations and effects.
In particular we're going to create the ability to easily add touch animations (specifically with touch down and up interactions) and discuss whether a plugin is needed or if submitting a pull request to NativeScript may be a better way to go. Here's a glimpse of what we will achieve and make it easy to control this behavior (per view or even automatically for any view with tap bindings via a few settings):
|
|
There's plenty of ways to enable animations via touch interactions with NativeScript. For example, let's briefly discuss 3 popular ways:
A. wire up a tap
event to trigger an animation on the tapped view.
B. wire up some other gestures
to queue up animations on views that trigger those gestures.
C. wire up the loaded
event to grab the view instance and manually configure native gestures.
Believe it or not, option C is the only one that would give you the most amount of flexibility however it comes at a cost of code overhead which is not what most would consider easy.
With option A, a tap
binding fires only after a touch down and up occurs. This is limiting because often we want to trigger an animation right when the finger touches down on the screen, not to mention a user would expect any animation on touch down to stay in that state until they lift their finger off the screen. A tap
binding is not going to fit the bill.
<Button tap="{{ tapMe }}" />
export function tapMe() {
// only fires after user lifts their finger off the button
}
With option B, there are plenty of useful gestures that @nativescript/core provides as a convenience, such as doubleTap
, longPress
, swipe
, pan
, pinch
, rotation
and touch
. While these may fit the bill for wiring up app logic and other behaviors including some animations, you could not easily customize the settings of some of the provided gesture handling to dial things just as you may need.
<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
}
With option C, you have full control over the native view instance to do whatever you'd like, however it's completely manual and requires a good bit of boilerplate to build things out for each scenario.
<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
}
}
This is flexible and personally I've done this option the most; however, with iOS, you have to consider if the instance is UIView based only, or whether it's enriched with UIControl APIs because you would want to handle gestures differently for either.
When building out any app, having skills to do these things is always beneficial; however, whether you're just starting out, or a 20 year veteran pro, everyone wants to build things efficiently so we need a better way.
Let's focus on touch down/up behavior for literally anything visually on the screen the user may touch down on and subsequently lift their finger off (touch up) at some point later. The most common case is of course any "tappable" visual element, though let's not constrain our approach to ensure this can be useful for elements which may not even have explicit tap
bindings.
We could probably build a plugin to provide this behavior but let's consider a few things first.
It's always a good idea when thinking about enhancing any view level details to hash out how you'd like to use it first because while doing so, it can often help define various cases that are good to know up front before attempting to write new features.
Note: If you have some interesting ideas and would like to bring in the community's feedback on which directions to take we also strongly encourage writing an RFC - Request for Comments discussion. You can read more about the NativeScript RFC process here.
💡 It would be pretty nice if we could simply be declarative with how we want a view to behave:
<Button touchAnimation="{{ touchAnimation }}" />
Being declarative helps quite a few things:
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,
},
}
It's hard to get more clear than that with intention and control. We declare the Button
has a touchAnimation
defined and it's bound to a definition which gives us animation control over the touch down and up state.
Let's not stop at the first thought here though because we love versatility when we can certainly have it. In addition to expressing NativeScript Animation APIs which are convenient, simple and easy - we would also like to define purely native animations like iOS UIView Animations or even Android Dynamic Spring Physics Animations. So it would also be nice to provide functions which pass along the View instance for super power control (because after all, we are using the most versatile tool in our developer toolbox, 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()
}
},
}
Now that packs a punch 👊 and is an immense amount of fun. However, you may have noticed that defining some native API animations can be verbose at times. Imagine defining those for each view that needs it 🤔 - You could reuse that single definition and bind it all over your declarative UI to enrich your view with interactive animations but again let's not stop at the second thought here either.
Note: 🤯 We could also enable via CSS properties but let's save CSS implementation details for a future post because what we're going to do here will cover the key details needed to even introduce new CSS properties later to enable this in the future. With NativeScript, focusing on a feature implementation first is always best because enabling via CSS later is just syntactic sugar you can do anytime.
Since we are focusing on touch down/up, arguably the most common way to enrich an app's interactivity as well as user experience with immediate touch feedback is to apply animations to any "tappable" element on the screen. Besides, the "tappable" elements will often get the most user "touching".
It would be nice if we could enable a boolean
which would auto detect any UI element which has a tap
binding and auto configure proper touch/up gestures with consistent animations we define once. Providing consistent touch feedback across all "tappable" surfaces in the app certainly gives it some nice polish.
We need something like a TouchManager
which would enable some of these convenient abilities, for example TouchManager.enableGlobalTapAnimations = true
but before we get ahead of ourselves let's start exploring our starting point with a new custom property and whether we can add such a thing via plugin alone.
If we want all View
elements in our app to have a new property such as touchAnimation
we could go about it by registering a new Property
to the base level view class in the hierachy, such as ViewBase
:
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...
This would work however would require users to import our plugin prior to the app bootstrapping which is not a huge deal so it's doable. Also notice the any
casting - we would need to do that a lot since the View
class would not offically support such a property.
Given that would give us a new property, how would we then apply those animations to the touch down/up gestures of the particular view that declares it?
We could then modify ViewBase
's prototype to override onLoaded
to check for whether the view instance declares the property to further wire up the needed gestures in one singular place which is guaranteed to have the native view instance we need to do so:
// ...
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...
How would we even add the potential to auto detect whether the view has a tap
binding and whether this new TouchManager
had enableGlobalTapAnimations
enabled to consider such a case?
// ...
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...
This would work even though modifying any class prototype could lead to trouble in the future based on whether underlying behavior changes with that particular class. So proceeding in this way runs some risk to future maintenance of a plugin that goes this direction.
Anyone can do this anytime they feel a feature may help them in their case while at the same time providing features that others may find useful as well. It's also quite fun working within the core codebase and realizing that quite an infinite level of possibilities are within reach.
Note: If you have some interesting ideas and would like to bring in the community's feedback on which directions to take we also strongly encourage writing an RFC - Request for Comments discussion. You can read more about the NativeScript RFC process here.
Let's create our own fork of the core repo and submit a pull request.
git clone https://github.com/NathanWalker/NativeScript.git
cd NativeScript
git checkout -b feat/touch-manager
npm run setup
We're now ready to add these features directly to core.
Since this adds new touch related features we can add our new TouchManager
to...
packages/core/ui/gestures/touch-manager.ts
:export class TouchManager {
static enableGlobalTapAnimations: boolean
// add more features as needed...
}
All various organized features within core maintain their own index.d.ts
since it provides various custom documentation and any other cross platform type handling as needed so let's ensure it's exported there so TypeScript can pick it up:
packages/core/ui/gestures/index.d.ts
:export * from './touch-manager'
We also want to ensure it's implementation is accessible from iOS and Android runtime contexts so we can also ensure it's exported from the common file for gestures:
packages/core/ui/gestures/gestures-common.ts
:export * from './touch-manager'
Lastly each section of core has it's own index.ts
which exposes entire folders or specific symbols as needed so we can make sure TouchManager
will be accessible from @nativescript/core
by adding it's symbol export:
packages/core/ui/index.ts
:export {
GesturesObserver,
TouchAction,
GestureTypes,
GestureStateTypes,
SwipeDirection,
GestureEvents,
TouchManager,
} from './gestures' // <-- added TouchManager
We now have a master switch to globally turn on any animations we'd like for all "tappable" elements in the UI - automatically.
TouchManager.enableGlobalTapAnimations = true;
Cool 😎
We can now add our new touchAnimation
property directly inside ViewCommon
, just as we would have done via a plugin that would register itself to ViewCommon
. We can start to define our own types to help strongly type all these features as well.
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)
While working through modifications in core you can begin to try things out using the apps/toolbox
app which exists in the repo for just this purpose. We can start the toolbox using the following options:
$ npm start
# type `toolbox.ios` or `toolbox.android` and hit ENTER
You can create a page for any new feature you'd like to develop out or just experiment with inside apps/toolbox/src/pages
.
While working through things you will often find ways to optimize the implementation as well as improve your original thoughts.
Here's the pull request I put together to allow everyone to benefit from this.
With all we have done here, we now have the ability to even apply consistent touch down/up animations to anything "tappable" in our entire app with 2 settings - yes, the entire app (lazy loaded, navigated to, popped up or anything you could dream up):
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
If you have a few "tappable" views that need to be ignored:
<Button text="Global tap animations simply ignored" ignoreTouchAnimation="true"/>
Well above we were exploring usage of some Android APIs which are not included out of the box with stock Android apps:
const androidAnimation = androidx.dynamicanimation.animation
const spring = new androidAnimation.SpringForce(0.95)
.setDampingRatio(androidAnimation.SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
.setStiffness(androidAnimation.SpringForce.STIFFNESS_MEDIUM)
Further it would be nice to create some nice plug & play animation effects that we could just use with the new touchAnimation
property as well. Since typically pull requests are not accepted to @nativescript/core which bring in third party libraries such as this, we do still have a need for a plugin here.
We could just add a plugin to our workspace we created in Part 1 however I always like to check the entire NativeScript community to see if adding such a plugin may make more sense to just contribute to a plugin that does something similar but may need this as well. I often google around for 'nativescript animations' (or 'nativescript effects') or look through npm searching for 'nativescript'.
Turns out there is a plugin which provides some nice NativeScript animation effects already:
Let's just fork that repo and provide another pull request to add some additional effects and features which make that plugin even better.
You can find another pull request I submitted to improve that plugin here - this will allow various plug/play animation effect definitions to be assigned to the new touchAnimation
property:
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 }}" />
Combining all we did above we now have a way to easily add nice interactivity to our app with ease - with the added bonus that you now can as well - these features will become part of the NativeScript 8.2 release!
|
|
The implementation of this you can find here.
This approach works for Angular, Vue, React, Svelte and any other flavor; like how about some SolidJS? ...without anything extra.