diff --git a/packages/core/ui/core/view-base/index.d.ts b/packages/core/ui/core/view-base/index.d.ts index b64af3aef..056357e22 100644 --- a/packages/core/ui/core/view-base/index.d.ts +++ b/packages/core/ui/core/view-base/index.d.ts @@ -229,6 +229,16 @@ export abstract class ViewBase extends Observable { */ public static unloadedEvent: string; + /** + * String value used when hooking to creation event + */ + public static createdEvent: string; + + /** + * String value used when hooking to disposeNativeView event + */ + public static disposeNativeViewEvent: string; + public ios: any; public android: any; diff --git a/packages/core/ui/core/view-base/index.ts b/packages/core/ui/core/view-base/index.ts index 474fc372c..efca848ce 100644 --- a/packages/core/ui/core/view-base/index.ts +++ b/packages/core/ui/core/view-base/index.ts @@ -248,7 +248,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition public static loadedEvent = 'loaded'; public static unloadedEvent = 'unloaded'; public static createdEvent = 'created'; - public static disposeNativeView = 'disposeNativeView'; + public static disposeNativeViewEvent = 'disposeNativeView'; private _onLoadedCalled = false; private _onUnloadedCalled = false; @@ -765,7 +765,7 @@ export abstract class ViewBase extends Observable implements ViewBaseDefinition public disposeNativeView() { this.notify({ - eventName: ViewBase.disposeNativeView, + eventName: ViewBase.disposeNativeViewEvent, object: this, }); } diff --git a/packages/core/ui/core/view/index.d.ts b/packages/core/ui/core/view/index.d.ts index 80c548594..7f056306e 100644 --- a/packages/core/ui/core/view/index.d.ts +++ b/packages/core/ui/core/view/index.d.ts @@ -8,7 +8,9 @@ import { LinearGradient } from '../../styling/linear-gradient'; import { AccessibilityLiveRegion, AccessibilityRole, AccessibilityState, AccessibilityTrait, AccessibilityEventOptions } from '../../../accessibility/accessibility-types'; import { CoreTypes } from '../../../core-types'; import { CSSShadow } from '../../styling/css-shadow'; +import { ViewCommon } from './view-common'; +export * from './view-common'; // helpers (these are okay re-exported here) export * from './view-helper'; @@ -99,7 +101,7 @@ export interface ShownModallyData extends EventData { * This class is the base class for all UI components. * A View occupies a rectangular area on the screen and is responsible for drawing and layouting of all UI components within. */ -export abstract class View extends ViewBase { +export abstract class View extends ViewCommon { /** * String value used when hooking to layoutChanged event. */ diff --git a/packages/core/ui/core/view/view-common.ts b/packages/core/ui/core/view/view-common.ts index 2b2ff610d..4e9f3d80f 100644 --- a/packages/core/ui/core/view/view-common.ts +++ b/packages/core/ui/core/view/view-common.ts @@ -4,6 +4,7 @@ import { View as ViewDefinition, Point, Size, ShownModallyData } from '.'; import { booleanConverter, ShowModalOptions, ViewBase } from '../view-base'; import { getEventOrGestureName } from '../bindable'; import { layout } from '../../../utils'; +import { isObject } from '../../../utils/types'; import { Color } from '../../../color'; import { Property, InheritedProperty } from '../properties'; import { EventData } from '../../../data/observable'; @@ -13,7 +14,7 @@ import { ViewHelper } from './view-helper'; import { PercentLength } from '../../styling/style-properties'; -import { observe as gestureObserve, GesturesObserver, GestureTypes, GestureEventData, fromString as gestureFromString } from '../../gestures'; +import { observe as gestureObserve, GesturesObserver, GestureTypes, GestureEventData, fromString as gestureFromString, TouchManager, TouchAnimationOptions } from '../../gestures'; import { CSSUtils } from '../../../css/system-classes'; import { Builder } from '../../builder'; @@ -81,6 +82,9 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { public accessibilityValue: string; public accessibilityHint: string; + public touchAnimation: boolean | TouchAnimationOptions; + public ignoreTouchAnimation: boolean; + protected _closeModalCallback: Function; public _manager: any; public _modalParent: ViewCommon; @@ -153,6 +157,17 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { } } + onLoaded() { + if (!this.isLoaded) { + const enableTapAnimations = TouchManager.enableGlobalTapAnimations && (this.hasListeners('tap') || this.hasListeners('tapChange') || this.getGestureObservers(GestureTypes.tap)); + if (!this.ignoreTouchAnimation && (this.touchAnimation || enableTapAnimations)) { + console.log('view:', Object.keys((this)._observers)); + TouchManager.addAnimations(this); + } + } + super.onLoaded(); + } + public _closeAllModalViewsInternal(): boolean { if (_rootModalViews && _rootModalViews.length > 0) { _rootModalViews.forEach((v) => { @@ -399,7 +414,7 @@ export abstract class ViewCommon extends ViewBase implements ViewDefinition { }; } - protected abstract _hideNativeModalView(parent: ViewCommon, whenClosedCallback: () => void); + protected _hideNativeModalView(parent: ViewCommon, whenClosedCallback: () => void) {} protected _raiseLayoutChangedEvent() { const args: EventData = { @@ -1151,7 +1166,31 @@ export const iosIgnoreSafeAreaProperty = new InheritedProperty({ valueConverter: booleanConverter, }); iosIgnoreSafeAreaProperty.register(ViewCommon); -accessibilityIdentifierProperty.register(ViewCommon); + +const touchAnimationProperty = new Property({ + name: 'touchAnimation', + valueChanged(view, oldValue, newValue) { + view.touchAnimation = newValue; + }, + valueConverter(value) { + if (isObject(value)) { + return value; + } else { + return booleanConverter(value); + } + }, +}); +touchAnimationProperty.register(ViewCommon); + +const ignoreTouchAnimationProperty = new Property({ + name: 'ignoreTouchAnimation', + valueChanged(view, oldValue, newValue) { + view.ignoreTouchAnimation = newValue; + }, + valueConverter: booleanConverter, +}); +ignoreTouchAnimationProperty.register(ViewCommon); + accessibilityLabelProperty.register(ViewCommon); accessibilityValueProperty.register(ViewCommon); accessibilityHintProperty.register(ViewCommon); diff --git a/packages/core/ui/gestures/gestures-common.ts b/packages/core/ui/gestures/gestures-common.ts index cbc8db93b..0e3d61750 100644 --- a/packages/core/ui/gestures/gestures-common.ts +++ b/packages/core/ui/gestures/gestures-common.ts @@ -1,8 +1,12 @@ import { GestureEventData, GesturesObserver as GesturesObserverDefinition } from '.'; import { View } from '../core/view'; +export * from './touch-manager'; + export enum GestureEvents { gestureAttached = 'gestureAttached', + touchDown = 'touchDown', + touchUp = 'touchUp', } export enum GestureTypes { diff --git a/packages/core/ui/gestures/index.d.ts b/packages/core/ui/gestures/index.d.ts index 1de495feb..1f67a06f8 100644 --- a/packages/core/ui/gestures/index.d.ts +++ b/packages/core/ui/gestures/index.d.ts @@ -1,6 +1,8 @@ import { View } from '../core/view'; import { EventData } from '../../data/observable'; +export * from './touch-manager'; + /** * Events emitted during gesture lifecycle */ @@ -10,6 +12,14 @@ export enum GestureEvents { * Provides access to the native gesture recognizer for further customization */ gestureAttached = 'gestureAttached', + /** + * When a touch down was detected + */ + touchDown = 'touchDown', + /** + * When a touch up was detected + */ + touchUp = 'touchUp', } /** diff --git a/packages/core/ui/gestures/touch-manager.ts b/packages/core/ui/gestures/touch-manager.ts new file mode 100644 index 000000000..8dc37ad9f --- /dev/null +++ b/packages/core/ui/gestures/touch-manager.ts @@ -0,0 +1,251 @@ +/** + * Provides various helpers for adding easy touch handling animations. + * Use when needing to implement more interactivity with your UI regarding touch down/up behavior. + */ +import { GestureEventData, GestureEventDataWithState, TouchGestureEventData } from '.'; +import { Animation } from '../animation'; +import { AnimationDefinition } from '../animation/animation-interfaces'; +import { View } from '../core/view'; +import { isObject, isFunction } from '../../utils/types'; +import { GestureEvents, GestureStateTypes, GestureTypes } from './gestures-common'; + +export type TouchAnimationFn = (view: View) => void; +export type TouchAnimationOptions = { + up?: TouchAnimationFn | AnimationDefinition; + down?: TouchAnimationFn | AnimationDefinition; +}; +export enum TouchAnimationTypes { + up = 'up', + down = 'down', +} + +/** + * Manage interactivity in your apps easily with TouchManager. + * Store reusable down/up animation settings for touches as well as optionally enable automatic tap (down/up) animations for your app. + */ +export class TouchManager { + /** + * Enable animations for all tap bindings in the UI. + */ + static enableGlobalTapAnimations: boolean; + /** + * Define reusable touch animations to use on views with touchAnimation defined or with enableGlobalTapAnimations on. + */ + static animations: TouchAnimationOptions; + /** + * Native Touch handlers (iOS only) registered with the view through the TouchManager. + * The TouchManager uses this internally but makes public for other versatility if needed. + */ + static touchHandlers: Array<{ view: View; handler: any /* UIGestureRecognizer */ }>; + /** + * When using NativeScript AnimationDefinition's for touch animations this will contain any instances for finer grain control of starting/stopping under various circumstances. + * The TouchManager uses this internally but makes public for other versatility if needed. + */ + static touchAnimationDefinitions: Array<{ view: View; animation: Animation; type: TouchAnimationTypes }>; + /** + * The TouchManager uses this internally. + * Adds touch animations to view based upon it's touchAnimation property or TouchManager.animations. + * @param view NativeScript view instance + */ + static addAnimations(view: View) { + // console.log("tapHandler:", tapHandler); + const handleDown = (view?.touchAnimation && (view?.touchAnimation).down) || (TouchManager.animations && TouchManager.animations.down); + const handleUp = (view?.touchAnimation && (view?.touchAnimation).up) || (TouchManager.animations && TouchManager.animations.up); + + if (global.isIOS) { + if (view?.ios?.addTargetActionForControlEvents) { + // can use UIControlEvents + console.log('added UIControlEvents!'); + if (!TouchManager.touchHandlers) { + TouchManager.touchHandlers = []; + } + TouchManager.touchHandlers.push({ + view, + handler: TouchControlHandler.initWithOwner(new WeakRef(view)), + }); + + if (handleDown) { + (view.ios).addTargetActionForControlEvents(TouchManager.touchHandlers[TouchManager.touchHandlers.length - 1].handler, GestureEvents.touchDown, UIControlEvents.TouchDown | UIControlEvents.TouchDragEnter); + view.on(GestureEvents.touchDown, (args) => { + console.log('touchDown {N} event'); + TouchManager.startAnimationForType(view, TouchAnimationTypes.down); + }); + } + if (handleUp) { + (view.ios).addTargetActionForControlEvents(TouchManager.touchHandlers[TouchManager.touchHandlers.length - 1].handler, GestureEvents.touchUp, UIControlEvents.TouchDragExit | UIControlEvents.TouchCancel | UIControlEvents.TouchUpInside | UIControlEvents.TouchUpOutside); + view.on(GestureEvents.touchUp, (args) => { + console.log('touchUp {N} event'); + TouchManager.startAnimationForType(view, TouchAnimationTypes.up); + }); + } + } else { + // console.log("use UILongPressGestureRecognizer!"); + console.log('added longPress to:', view.id); + + if (handleDown || handleUp) { + view.on(GestureEvents.gestureAttached, (args: GestureEventData) => { + if (args.type === GestureTypes.longPress) { + (args.ios).minimumPressDuration = 0; + } + }); + view.on(GestureTypes.longPress, (args: GestureEventDataWithState) => { + switch (args.state) { + case GestureStateTypes.began: + if (handleDown) { + console.log('longPress began:', args.view.id, args.state); + TouchManager.startAnimationForType(args.view, TouchAnimationTypes.down); + } + break; + case GestureStateTypes.cancelled: + case GestureStateTypes.ended: + if (handleUp) { + TouchManager.startAnimationForType(args.view, TouchAnimationTypes.up); + } + break; + } + }); + } + } + } else { + if (handleDown || handleUp) { + view.on(GestureTypes.touch, (args: TouchGestureEventData) => { + switch (args.action) { + case 'down': + if (handleDown) { + view.notify({ + eventName: GestureEvents.touchDown, + object: view, + data: args.android, + }); + } + break; + case 'up': + case 'cancel': + if (handleUp) { + view.notify({ + eventName: GestureEvents.touchUp, + object: view, + data: args.android, + }); + } + break; + } + }); + if (handleDown) { + view.on(GestureEvents.touchDown, (args) => { + console.log('touchDown {N} event'); + TouchManager.startAnimationForType(view, TouchAnimationTypes.down); + }); + } + if (handleUp) { + view.on(GestureEvents.touchUp, (args) => { + console.log('touchUp {N} event'); + TouchManager.startAnimationForType(view, TouchAnimationTypes.up); + }); + } + } + } + + view.on(View.disposeNativeViewEvent, (args) => { + console.log('calling disposeNativeView:', args.eventName, 'TouchManager.touchHandlers.length:', TouchManager.touchHandlers.length); + const index = TouchManager.touchHandlers?.findIndex((handler) => handler.view === args.object); + if (index > -1) { + TouchManager.touchHandlers.splice(index, 1); + } + TouchManager.touchAnimationDefinitions = TouchManager.touchAnimationDefinitions?.filter((d) => d.view !== args.object); + console.log('after clearing with disposeNativeView:', args.eventName, 'TouchManager.touchHandlers.length:', TouchManager.touchHandlers.length); + console.log('TouchManager.touchAnimationDefinitions.length:', TouchManager.touchAnimationDefinitions.length); + }); + } + + static startAnimationForType(view: View, type: TouchAnimationTypes) { + if (view) { + const animate = function (definition: AnimationDefinition | TouchAnimationFn) { + if (definition) { + if (isFunction(definition)) { + (definition)(view); + } else { + if (!TouchManager.touchAnimationDefinitions) { + TouchManager.touchAnimationDefinitions = []; + } + // reuse animations for each type + let touchAnimation: Animation; + // triggering animations should always cancel other animations which may be in progress + for (const d of TouchManager.touchAnimationDefinitions) { + if (d.view === view && d.animation) { + d.animation.cancel(); + if (d.type === type) { + touchAnimation = d.animation; + } + } + } + + if (!touchAnimation) { + touchAnimation = new Animation([ + { + target: view, + ...(definition), + }, + ]); + TouchManager.touchAnimationDefinitions.push({ + view, + type, + animation: touchAnimation, + }); + } + touchAnimation.play().catch(() => {}); + } + } + }; + // always use instance defined animation over global + if (isObject(view.touchAnimation) && view.touchAnimation[type]) { + animate((view).touchAnimation[type]); + } else if (TouchManager.animations?.[type]) { + // fallback to globally defined + animate(TouchManager.animations?.[type]); + } + } + } +} + +export let TouchControlHandler: { + initWithOwner: (owner: WeakRef) => any; +}; +ensureTouchControlHandlers(); + +function ensureTouchControlHandlers() { + if (global.isIOS) { + @NativeClass + class TouchHandlerImpl extends NSObject { + private _owner: WeakRef; + static ObjCExposedMethods = { + touchDown: { returns: interop.types.void, params: [interop.types.id] }, + touchUp: { returns: interop.types.void, params: [interop.types.id] }, + }; + + static initWithOwner(owner: WeakRef): TouchHandlerImpl { + const handler = TouchHandlerImpl.new(); + handler._owner = owner; + return handler; + } + + touchDown(args) { + this._owner?.get?.().notify({ + eventName: GestureEvents.touchDown, + object: this._owner?.get?.(), + data: args, + }); + } + + touchUp(args) { + this._owner?.get?.().notify({ + eventName: GestureEvents.touchUp, + object: this._owner?.get?.(), + data: args, + }); + } + } + + TouchControlHandler = TouchHandlerImpl; + } +} diff --git a/packages/core/ui/index.ts b/packages/core/ui/index.ts index 5206d4f52..3424c7735 100644 --- a/packages/core/ui/index.ts +++ b/packages/core/ui/index.ts @@ -27,8 +27,8 @@ export * from './editable-text-base'; export { Frame, setActivityCallbacks } from './frame'; export type { NavigationEntry, NavigationContext, NavigationTransition, BackstackEntry, ViewEntry, AndroidActivityCallbacks } from './frame'; -export { GesturesObserver, TouchAction, GestureTypes, GestureStateTypes, SwipeDirection, GestureEvents } from './gestures'; -export type { GestureEventData, GestureEventDataWithState, TapGestureEventData, PanGestureEventData, PinchGestureEventData, RotationGestureEventData, SwipeGestureEventData, TouchGestureEventData } from './gestures'; +export { GesturesObserver, TouchAction, GestureTypes, GestureStateTypes, SwipeDirection, GestureEvents, TouchManager } from './gestures'; +export type { GestureEventData, GestureEventDataWithState, TapGestureEventData, PanGestureEventData, PinchGestureEventData, RotationGestureEventData, SwipeGestureEventData, TouchGestureEventData, TouchAnimationOptions } from './gestures'; export { HtmlView } from './html-view'; export { Image } from './image';