@nativescript-community/ui-persistent-bottomsheet
Version:
NativeScript plugin that allows you to easily add a persistent bottomsheet to your projects.
648 lines • 25.3 kB
JavaScript
import { GestureHandlerStateEvent, GestureHandlerTouchEvent, GestureState, HandlerType, Manager, install as installGestures } from '@nativescript-community/gesturehandler';
import { AbsoluteLayout, Animation, CSSType, Color, CoreTypes, Property, Utils, booleanConverter } from '@nativescript/core';
import { VelocityTracker } from './VelocityTracker';
const OPEN_DURATION = 200;
export let PAN_GESTURE_TAG = 12400;
const SWIPE_DISTANCE_MINIMUM = 10;
function transformAnimationValues(values) {
values.translate = { x: values.translateX || 0, y: values.translateY || 0 };
values.scale = { x: values.scaleX || 1, y: values.scaleY || 1 };
delete values.translateX;
delete values.translateY;
delete values.scaleX;
delete values.scaleY;
return values;
}
export const scrollViewProperty = new Property({
name: 'scrollViewId',
defaultValue: undefined,
valueChanged: (target, oldValue, newValue) => {
target._onScrollViewIdChanged(oldValue, newValue);
}
});
export const bottomSheetProperty = new Property({
name: 'bottomSheet',
defaultValue: undefined,
valueChanged: (target, oldValue, newValue) => {
target._onBottomSheetChanged(oldValue, newValue);
}
});
export const gestureEnabledProperty = new Property({
name: 'gestureEnabled',
defaultValue: true,
valueConverter: booleanConverter
});
export const stepIndexProperty = new Property({
name: 'stepIndex',
defaultValue: 0
});
export const backdropColorProperty = new Property({
name: 'backdropColor',
valueConverter: (c) => (c ? new Color(c) : null)
});
export const translationFunctionProperty = new Property({
name: 'translationFunction'
});
let PersistentBottomSheet = class PersistentBottomSheet extends AbsoluteLayout {
constructor() {
super();
// isPanning = false;
this.backdropColor = null;
this.stepIndex = 0;
this.panGestureOptions = null;
this._steps = [70];
this.isAnimating = false;
this.prevDeltaY = 0;
this.viewHeight = 0;
this.wasDraggingPanel = false;
this.gestureModeDecided = false;
this._translationY = -1;
this.gestureEnabled = true;
this._isScrollEnabled = true;
this._allowBottomSheetAdd = false;
this.dragToss = 0.05;
this.scrollViewTouched = false;
this.vt = null;
this.animating = false;
this.isPassThroughParentEnabled = true;
this.on('layoutChanged', this.onLayoutChanged, this);
}
get steps() {
const result = this._steps || (this.bottomSheet && this.bottomSheet.steps);
return result;
}
set steps(value) {
this._steps = value;
if (this._steps?.length) {
this.alignToStepPosition();
}
}
initGestures() {
if (this.scrollView) {
this.scrollView.on('touch', this.onScrollViewTouch, this);
}
// On Android, also listen to touch events on bottomSheet to detect gestures that start
// on tap-enabled elements (buttons, etc.). On iOS, attaching a touch listener to the
// bottomSheet container would intercept and block tap events on child elements, so we
// only listen to scrollView events which is sufficient for iOS gesture handling.
if (__ANDROID__ && this.bottomSheet) {
this.bottomSheet.on('touch', this.onBottomSheetTouch, this);
}
const manager = Manager.getInstance();
const options = { gestureId: PAN_GESTURE_TAG++, ...this.panGestureOptions };
const gestureHandler = manager.createGestureHandler(HandlerType.PAN, options.gestureId, {
shouldStartGesture: this.shouldStartGesture.bind(this),
// simultaneousHandlers: [NATIVE_GESTURE_TAG],
minDist: SWIPE_DISTANCE_MINIMUM,
...options
});
gestureHandler.on(GestureHandlerTouchEvent, this.onGestureTouch, this);
gestureHandler.on(GestureHandlerStateEvent, this.onGestureState, this);
gestureHandler.attachToView(this);
this.panGestureHandler = gestureHandler;
}
shouldStartGesture(data) {
if (this.steps.length === 0 || (this.steps.length === 1 && this.steps[0] === 0) || (this.stepIndex === 0 && this.steps[0] === 0)) {
return false;
}
let deltaY = 0;
if (__IOS__ && !this.iosIgnoreSafeArea) {
deltaY -= Utils.layout.toDeviceIndependentPixels(this.getSafeAreaInsets().top);
}
const y = data.y + deltaY;
if (y < this.viewHeight + this.translationY) {
return false;
}
if (this._scrollView) {
const posY = this._scrollView && this.scrollView.getLocationRelativeTo(this).y + deltaY;
if (y >= posY && y <= posY + this.scrollView.getMeasuredHeight()) {
return false;
}
}
return true;
}
get translationY() {
return this._translationY;
}
set translationY(value) {
// Do we still need this? it had some bad side effect
// where the scrollview would start with isScrollEnabled= false from alignToStepPosition
// if (this._translationY !== -1) {
// this.isScrollEnabled = value === 0;
// }
this._translationY = value;
}
get translationMaxOffset() {
const steps = this.steps;
return steps[steps.length - 1];
}
initNativeView() {
super.initNativeView();
if (this.gestureEnabled) {
this.initGestures();
}
}
disposeNativeView() {
// this.off('layoutChanged', this.onLayoutChange, this);
if (this.scrollView) {
this.scrollView.off('touch', this.onScrollViewTouch, this);
this.scrollView = null;
}
if (__ANDROID__ && this.bottomSheet) {
this.bottomSheet.off('touch', this.onBottomSheetTouch, this);
this.bottomSheet = null;
}
super.disposeNativeView();
if (this.panGestureHandler) {
this.panGestureHandler.off(GestureHandlerTouchEvent, this.onGestureTouch, this);
this.panGestureHandler.off(GestureHandlerStateEvent, this.onGestureState, this);
this.panGestureHandler.detachFromView();
this.panGestureHandler = null;
}
// if (this.nativeGestureHandler) {
// // this.nativeGestureHandler.off(GestureHandlerTouchEvent, this.onNativeGestureTouch, this);
// // this.nativeGestureHandler.off(GestureHandlerStateEvent, this.onNativeGestureState, this);
// this.nativeGestureHandler.detachFromView();
// this.nativeGestureHandler = null;
// }
}
[gestureEnabledProperty.setNative](value) {
if (this.panGestureHandler) {
this.panGestureHandler.enabled = value;
}
else if (value && !this.panGestureHandler) {
this.initGestures();
}
}
[stepIndexProperty.setNative](value) {
if (this.viewHeight !== 0) {
// we are layed out
this.animateToPosition(this.steps[value]);
}
}
[backdropColorProperty.setNative](value) {
if (!this.backDrop && this.bottomSheet) {
const index = this.getChildIndex(this.bottomSheet);
this.addBackdropView(index);
}
}
addBackdropView(index) {
this.backDrop = new AbsoluteLayout();
this.backDrop.width = this.backDrop.height = {
unit: '%',
value: 100
};
this.backDrop.backgroundColor = this.backdropColor;
this.backDrop.opacity = 0;
this.backDrop.isUserInteractionEnabled = false;
this.insertChild(this.backDrop, index);
}
get scrollView() {
return this._scrollView;
}
set scrollView(value) {
if (this._scrollView === value) {
return;
}
if (this._scrollView) {
this.scrollView.off('touch', this.onScrollViewTouch, this);
}
this._scrollView = value;
if (value) {
if (__IOS__) {
// Disable bounce effect to prevent scroll acceleration (2x speed issue)
// and to allow panel dragging when reaching scroll boundaries.
value.nativeViewProtected.bounces = false;
}
if (this.gestureEnabled) {
value.on('touch', this.onScrollViewTouch, this);
}
value.isScrollEnabled = this._isScrollEnabled;
}
}
_onScrollViewIdChanged(oldValue, newValue) {
if (newValue && this.bottomSheet) {
if (this.bottomSheet.isLoaded) {
const view = this.bottomSheet.getViewById(newValue);
this.scrollView = view;
}
else {
this.bottomSheet.once('loaded', () => {
const view = this.bottomSheet.getViewById(newValue);
this.scrollView = view;
});
}
}
else {
this.scrollView = null;
}
}
addChild(child) {
if (child === this.bottomSheet && !this._allowBottomSheetAdd) {
return;
}
super.addChild(child);
}
_onBottomSheetChanged(oldValue, newValue) {
if (oldValue === newValue) {
return;
}
if (oldValue) {
this.removeChild(oldValue);
}
if (newValue) {
newValue.iosOverflowSafeAreaEnabled = false;
if (!newValue.width) {
newValue.width = {
unit: '%',
value: 100
};
}
// newValue.top = {
// unit: 'px',
// value: this.viewHeight
// };
// newValue.verticalAlignment = 'bottom';
// newValue.on('layoutChanged', this.onBottomLayoutChange, this);
let index;
if (!newValue.parent) {
index = this.getChildrenCount();
this._allowBottomSheetAdd = true;
this.addChild(newValue);
this._allowBottomSheetAdd = false;
}
else {
index = this.getChildIndex(newValue);
}
if (!this.backDrop && this.backdropColor) {
this.addBackdropView(index);
}
if (this.scrollViewId) {
this._onScrollViewIdChanged(null, this.scrollViewId);
}
}
}
computeTranslationData() {
const max = this.translationMaxOffset;
let value = this._translationY;
const progress = -value / max;
if (__IOS__ && progress === 0 && !this.iosIgnoreSafeArea) {
// if this is the 0 steop ensure it gets hidden even with safeArea
const safeArea = this.getSafeAreaInsets();
value += Utils.layout.toDeviceIndependentPixels(safeArea.bottom);
}
if (this.translationFunction) {
return this.translationFunction(value, max, progress);
}
return {
bottomSheet: {
translateY: value
},
backDrop: {
opacity: progress
}
};
}
alignToStepPosition() {
if (!this.bottomSheet) {
return;
}
const steps = this.steps;
const step = steps[Math.min(this.stepIndex, steps.length - 1)] ?? 0;
const ty = step;
this.translationY = -ty;
const data = this.computeTranslationData();
this.applyTrData(data);
}
onLayoutChanged(event) {
const contentView = event.object;
const height = Math.round(Utils.layout.toDeviceIndependentPixels(contentView.getMeasuredHeight()));
this.viewHeight = height;
if (this.bottomSheet) {
this.bottomSheet.top = {
unit: 'px',
value: contentView.getMeasuredHeight()
};
}
if (this.translationY === -1) {
this.alignToStepPosition();
}
}
get scrollViewVerticalOffset() {
if (__ANDROID__) {
return this.scrollView.nativeViewProtected.computeVerticalScrollOffset() / Utils.layout.getDisplayDensity();
}
else {
return this.scrollView.nativeViewProtected.contentOffset.y;
}
}
// private set scrollViewVerticalOffset(value: number) {
// if (__ANDROID__) {
// (this.scrollView.nativeViewProtected as androidx.recyclerview.widget.RecyclerView).scrollTo(0, 0);
// } else {
// (this.scrollView.nativeViewProtected as UIScrollView).contentOffset = CGPointMake(this.scrollView.nativeViewProtected.contentOffset.x, 0);
// }
// }
get isScrollEnabled() {
return this._isScrollEnabled;
}
set isScrollEnabled(value) {
if (this._isScrollEnabled !== value) {
this._isScrollEnabled = value;
if (this.scrollView) {
// this only works if the scrollView component supports isScrollEnabled
// otherwise the interaction wont be nice
this.scrollView.isScrollEnabled = value;
}
}
}
onBottomSheetTouch(event) {
if (!this.scrollViewTouched) {
this.onTouch(event);
}
}
onScrollViewTouch(event) {
if (event.action === 'up' || event.action === 'cancel') {
this.scrollViewTouched = false;
}
else {
this.scrollViewTouched = true;
}
this.onTouch(event);
}
onTouch(event) {
let touchY;
// touch event gives you relative touch which varies with translateY
// so we use touch location in the window
if (__ANDROID__) {
touchY = Utils.layout.toDeviceIndependentPixels(event.android.getRawY());
}
else if (__IOS__) {
touchY = event.ios.touches.anyObject().locationInView(null).y;
}
const p = event.getActivePointers()[0];
if (event.action === 'down') {
this.vt = VelocityTracker.obtain();
this.vt.addMovement(p.getX(), p.getY());
this.lastTouchY = null;
this.touchStartY = touchY;
this.wasDraggingPanel = false;
this.gestureModeDecided = false;
}
else if (event.action === 'up' || event.action === 'cancel') {
this.vt?.addMovement(p.getX(), p.getY());
// If we were dragging the panel, animate to nearest step
// BUT: ignore 'cancel' events from tap handlers (they shouldn't trigger animation)
if (this.wasDraggingPanel && event.action === 'up') {
this.vt?.computeCurrentVelocity(1000);
const velocityY = -(this.vt?.getYVelocity() ?? 0);
this.vt?.recycle();
const y = touchY - (this.lastTouchY || touchY);
const totalDelta = this.translationY + y + this.dragToss * velocityY;
this.computeAndAnimateEndGestureAnimation(-totalDelta);
this.wasDraggingPanel = false;
}
// only reset on bottomsheet up/cancel event (will happen after scrollView up/cancel)
// because doing it in scrollView could trigger unwanted scroll events breaking things
if (this.scrollViewTouched) {
this.isScrollEnabled = true;
}
// Only reset touchStartY on 'up', not on 'cancel' (@tap elements send cancel mid-gesture)
// if (event.action === 'up') {
this.touchStartY = null;
// }
this.lastTouchY = null;
// Reset dragging state on any end event
this.wasDraggingPanel = false;
}
else if (event.action === 'move') {
// On Android sometimes we don't get the down event but we get move events
// so initialize touchStartY if needed
if (this.touchStartY === undefined) {
this.vt = VelocityTracker.obtain();
this.lastTouchY = null;
this.touchStartY = touchY;
this.wasDraggingPanel = false;
this.gestureModeDecided = false;
}
this.vt?.addMovement(p.getX(), p.getY());
const deltaY = touchY - this.touchStartY;
const absDeltaY = Math.abs(deltaY);
// we cant have small movement ignore
// otherwise the scrollview would slowly start moving
// then after it would peek a scrollview wanting to scroll
// if (absDeltaY < SWIPE_DISTANCE_MINIMUM) {
// // Movement too small - likely a tap
// return;
// }
// Check current scroll position
const currentScrollY = this._scrollView ? this.scrollViewVerticalOffset : 0;
const isAtTop = currentScrollY === 0;
// If not yet dragging panel, check if we should start (one-way transition)
if (!this.wasDraggingPanel) {
let shouldStartDraggingPanel = false;
if (deltaY > 0) {
// Swiping DOWN - start dragging panel if reached top
shouldStartDraggingPanel = isAtTop;
}
else {
// Swiping UP - start dragging panel if at top AND panel not fully expanded
if (isAtTop) {
const maxOffset = this.translationMaxOffset;
const isPanelFullyExpanded = Math.abs(this.translationY + maxOffset) < 1;
shouldStartDraggingPanel = !isPanelFullyExpanded;
}
}
if (shouldStartDraggingPanel) {
// Switch to panel dragging mode (one-way, won't switch back during this gesture)
this.wasDraggingPanel = true;
this.isScrollEnabled = false;
this.cancelAllGestures();
}
else {
// Keep scrolling the list
if (!this.gestureModeDecided && !isAtTop) {
this.gestureModeDecided = true;
this.isScrollEnabled = true;
}
}
}
else {
const maxOffset = this.translationMaxOffset;
const isPanelFullyExpanded = Math.abs(this.translationY + maxOffset) < 1;
// if dragged to the top we can enable back scrolling
if (isPanelFullyExpanded) {
this.wasDraggingPanel = false;
this.gestureModeDecided = true;
this.isScrollEnabled = true;
}
}
// Execute the current mode
if (this.wasDraggingPanel) {
// Handle panel drag
const y = touchY - (this.lastTouchY ?? touchY);
const trY = this.constrainY(this.translationY + y);
this.translationY = trY;
const trData = this.computeTranslationData();
this.applyTrData(trData);
}
// else: let native scroll happen (do nothing)
this.lastTouchY = touchY;
}
}
onGestureState(args) {
// Ignore pan gesture handler when we're manually dragging panel via onTouch
if (this.wasDraggingPanel) {
return;
}
const { state, prevState, extraData, view } = args.data;
if (prevState === GestureState.ACTIVE) {
const { velocityY, translationY } = extraData;
const y = translationY - this.prevDeltaY;
const totalDelta = this.translationY + (y + this.dragToss * velocityY);
this.computeAndAnimateEndGestureAnimation(-totalDelta);
this.prevDeltaY = 0;
}
}
canAnimateToStep(step) {
return true;
}
computeAndAnimateEndGestureAnimation(totalDelta) {
const steps = this.steps;
let stepIndex = 0;
for (let i = 0; i < steps.length; i++) {
if (!this.canAnimateToStep(i)) {
stepIndex++;
}
else {
break;
}
}
let destSnapPoint = steps[stepIndex];
let distance = Math.abs(destSnapPoint - totalDelta);
for (let i = stepIndex; i < steps.length; i++) {
const snapPoint = steps[i];
if (!this.canAnimateToStep(snapPoint)) {
continue;
}
const distFromSnap = Math.abs(snapPoint - totalDelta);
if (distFromSnap <= Math.abs(destSnapPoint - totalDelta)) {
destSnapPoint = snapPoint;
stepIndex = i;
distance = distFromSnap;
}
}
stepIndexProperty.nativeValueChange(this, stepIndex);
this.animateToPosition(destSnapPoint, Math.min(distance * 2, OPEN_DURATION));
}
animateStepIndex(stepIndex, duration, curve) {
const steps = this.steps;
stepIndexProperty.nativeValueChange(this, stepIndex);
const destSnapPoint = steps[stepIndex];
this.animateToPosition(destSnapPoint, duration, curve);
}
onGestureTouch(args) {
// Ignore pan gesture handler when we're manually dragging panel via onTouch
if (this.wasDraggingPanel) {
return;
}
const data = args.data;
if (data.state !== GestureState.ACTIVE) {
return;
}
const deltaY = data.extraData.translationY;
if (this.isAnimating || deltaY === 0) {
this.prevDeltaY = deltaY;
return;
}
const y = deltaY - this.prevDeltaY;
const trY = this.constrainY(this.translationY + y);
this.translationY = trY;
const trData = this.computeTranslationData();
this.applyTrData(trData);
this.prevDeltaY = deltaY;
}
applyTrData(trData) {
Object.keys(trData).forEach((k) => {
const { target, ...others } = trData[k];
if (target) {
Object.assign(target, others);
}
if (this[k]) {
Object.assign(this[k], others);
}
});
}
constrainY(y) {
return Math.max(Math.min(y, 0), -this.translationMaxOffset);
}
async animateToPosition(position, duration = OPEN_DURATION, curve = CoreTypes.AnimationCurve.easeOut) {
if (this.animation) {
this.animation.cancel();
}
if (this.animating) {
return;
}
this.notify({ eventName: 'animate', position, duration });
this.animating = true;
if (this._scrollView && __ANDROID__) {
// on android we get unwanted scroll effect while "swipping the view"
// cancel the views touches before animation to prevent that
const time = Date.now();
const event = android.view.MotionEvent.obtain(time, time, android.view.MotionEvent.ACTION_CANCEL, 0, 0, 0);
event.setAction(android.view.MotionEvent.ACTION_CANCEL);
this.scrollView.nativeViewProtected.dispatchTouchEvent(event);
}
// const height = this.bottomViewHeight;
this.translationY = -position;
const trData = this.computeTranslationData();
const params = Object.keys(trData)
.map((k) => {
const data = trData[k];
if (data.target) {
return Object.assign({
curve,
duration
}, transformAnimationValues(trData[k]));
}
else if (this[k]) {
return Object.assign({
target: this[k],
curve,
duration
}, transformAnimationValues(trData[k]));
}
})
.filter((a) => !!a);
try {
this.animation = new Animation(params);
await this.animation.play();
}
catch (err) {
//ensure we go to end position
this.applyTrData(trData);
console.error('BottomSheet animation cancelled', err);
}
finally {
this.isScrollEnabled = true;
this.animating = false;
this.panGestureHandler.enabled = this.stepIndex !== 0;
this.animation = null;
this.notify({ eventName: 'animated', position, duration });
}
}
};
PersistentBottomSheet = __decorate([
CSSType('PersistentBottomSheet')
], PersistentBottomSheet);
export { PersistentBottomSheet };
backdropColorProperty.register(PersistentBottomSheet);
scrollViewProperty.register(PersistentBottomSheet);
bottomSheetProperty.register(PersistentBottomSheet);
gestureEnabledProperty.register(PersistentBottomSheet);
translationFunctionProperty.register(PersistentBottomSheet);
stepIndexProperty.register(PersistentBottomSheet);
export function install() {
installGestures();
}
//# sourceMappingURL=index.js.map