From 89c9a4404f9cb8125e67bf097365914017189e8e Mon Sep 17 00:00:00 2001 From: Tom Tirapani Date: Tue, 21 Jan 2025 15:08:44 +0000 Subject: [PATCH] Rebuild mobile navigator without Onsen, and add smooth swipe behaviour (#3898) --- CHANGELOG.md | 11 +- cmp/ag-grid/AgGrid.scss | 6 + kit/onsen/index.ts | 1 - kit/onsen/theme.scss | 5 - kit/swiper/index.ts | 14 + kit/swiper/styles.scss | 2 + mobile/cmp/navigator/Navigator.scss | 20 ++ mobile/cmp/navigator/Navigator.ts | 55 ++-- mobile/cmp/navigator/NavigatorModel.ts | 255 +++++++++++------- .../Swiper.scss => GestureRefresh.scss} | 7 +- mobile/cmp/navigator/impl/GestureRefresh.ts | 55 ++++ .../cmp/navigator/impl/GestureRefreshModel.ts | 86 ++++++ mobile/cmp/navigator/impl/Page.scss | 6 +- mobile/cmp/navigator/impl/Page.ts | 22 +- mobile/cmp/navigator/impl/Utils.ts | 107 ++++++++ .../cmp/navigator/impl/swipe/BackIndicator.ts | 34 --- .../navigator/impl/swipe/RefreshIndicator.ts | 36 --- mobile/cmp/navigator/impl/swipe/Swiper.ts | 35 --- .../cmp/navigator/impl/swipe/SwiperModel.ts | 153 ----------- package.json | 1 + yarn.lock | 5 + 21 files changed, 518 insertions(+), 398 deletions(-) create mode 100644 kit/swiper/index.ts create mode 100644 kit/swiper/styles.scss create mode 100644 mobile/cmp/navigator/Navigator.scss rename mobile/cmp/navigator/impl/{swipe/Swiper.scss => GestureRefresh.scss} (90%) create mode 100644 mobile/cmp/navigator/impl/GestureRefresh.ts create mode 100644 mobile/cmp/navigator/impl/GestureRefreshModel.ts create mode 100644 mobile/cmp/navigator/impl/Utils.ts delete mode 100644 mobile/cmp/navigator/impl/swipe/BackIndicator.ts delete mode 100644 mobile/cmp/navigator/impl/swipe/RefreshIndicator.ts delete mode 100644 mobile/cmp/navigator/impl/swipe/Swiper.ts delete mode 100644 mobile/cmp/navigator/impl/swipe/SwiperModel.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6904fe8d5..b592efb1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## v72.0.0-SNAPSHOT - unreleased +### 💥 Breaking Changes + +* Mobile `Navigator` no longer supports `animation` prop, and `NavigatorModel` no longer supports + `swipeToGoBack`. Both of these properties are now managed internally by the `Navigator` component. + +### 🎁 New Features + +* Mobile `Navigator` has been rebuilt to support smooth swipe-based navigation. The API remains + largely the same, notwithstanding the minor breaking changes detailed above. + ### 🐞 Bug Fixes * Fixed `ViewManagerModel` unique name validation. @@ -11,7 +21,6 @@ * Added support for providing custom `PersistenceProvider` implementations to `PersistOptions`. - ### ⚙️ Typescript API Adjustments * Improved signature of `HoistBase.markPersist`. diff --git a/cmp/ag-grid/AgGrid.scss b/cmp/ag-grid/AgGrid.scss index 5d90af0ca..96118852e 100644 --- a/cmp/ag-grid/AgGrid.scss +++ b/cmp/ag-grid/AgGrid.scss @@ -26,6 +26,12 @@ .ag-floating-bottom { overflow-y: hidden !important; } + + // Prevent the "bounce" overscroll effect for horizontal scrolling on mobile devices, as it + // conflicts with the drag management in the Navigator. + .ag-center-cols-viewport { + overscroll-behavior-x: none; + } } // Ag-Grid themes referenced here to help ensure a high enough level of specificity for our rules. diff --git a/kit/onsen/index.ts b/kit/onsen/index.ts index d2a8c813e..7044328cb 100644 --- a/kit/onsen/index.ts +++ b/kit/onsen/index.ts @@ -23,7 +23,6 @@ export const [button, Button] = wrappedCmp(ons.Button), [checkbox, Checkbox] = wrappedCmp(ons.Checkbox), [gestureDetector, GestureDetector] = wrappedCmp(ons.GestureDetector), [input, Input] = wrappedCmp(ons.Input), - [navigator, Navigator] = wrappedCmp(ons.Navigator), [searchInput, SearchInput] = wrappedCmp(ons.SearchInput), [select, Select] = wrappedCmp(ons.Select), [switchControl, SwitchControl] = wrappedCmp(ons.Switch); diff --git a/kit/onsen/theme.scss b/kit/onsen/theme.scss index fd941d21e..e30cd679a 100644 --- a/kit/onsen/theme.scss +++ b/kit/onsen/theme.scss @@ -11,15 +11,10 @@ // Override styles provided in onsenui.css and onsen-css-components.css to use variable declarations body.xh-app.xh-mobile { // Background - .page, - .page__content, - .page__background, .textarea { background-color: var(--xh-bg); } - .page--material, - .page--material__background, .bottom-bar, .tabbar { background-color: var(--xh-bg-alt); diff --git a/kit/swiper/index.ts b/kit/swiper/index.ts new file mode 100644 index 000000000..88f31e433 --- /dev/null +++ b/kit/swiper/index.ts @@ -0,0 +1,14 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2025 Extremely Heavy Industries Inc. + */ +import {elementFactory} from '@xh/hoist/core'; +import {Swiper, SwiperSlide} from 'swiper/react'; +import {EffectCreative} from 'swiper/modules'; +import './styles.scss'; + +export {Swiper, SwiperSlide, EffectCreative}; +export const swiper = elementFactory(Swiper), + swiperSlide = elementFactory(SwiperSlide); diff --git a/kit/swiper/styles.scss b/kit/swiper/styles.scss new file mode 100644 index 000000000..1518dcf9a --- /dev/null +++ b/kit/swiper/styles.scss @@ -0,0 +1,2 @@ +@import 'swiper/scss'; +@import 'swiper/scss/effect-creative'; diff --git a/mobile/cmp/navigator/Navigator.scss b/mobile/cmp/navigator/Navigator.scss new file mode 100644 index 000000000..13028f779 --- /dev/null +++ b/mobile/cmp/navigator/Navigator.scss @@ -0,0 +1,20 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2025 Extremely Heavy Industries Inc. + */ +.xh-navigator { + width: 100%; + height: 100%; + + .swiper-slide { + box-shadow: + 0 0 0 1px var(--xh-border-color), + 0 0 20px rgba(0, 0, 0, 0.3); + } + + .div.swiper-container { + touch-action: pan-x; + } +} diff --git a/mobile/cmp/navigator/Navigator.ts b/mobile/cmp/navigator/Navigator.ts index 077d366d7..9ccb1c455 100644 --- a/mobile/cmp/navigator/Navigator.ts +++ b/mobile/cmp/navigator/Navigator.ts @@ -4,36 +4,53 @@ * * Copyright © 2025 Extremely Heavy Industries Inc. */ -import {hoistCmp, HoistProps, uses} from '@xh/hoist/core'; -import {navigator as onsenNavigator} from '@xh/hoist/kit/onsen'; +import {hoistCmp, uses} from '@xh/hoist/core'; +import {swiper, swiperSlide, EffectCreative} from '@xh/hoist/kit/swiper'; import '@xh/hoist/mobile/register'; -import {swiper} from './impl/swipe/Swiper'; +import './Navigator.scss'; import {NavigatorModel} from './NavigatorModel'; - -export interface NavigatorProps extends HoistProps { - /** Set animation style or turn off, default 'slide'. */ - animation?: 'slide' | 'lift' | 'fade' | 'none'; -} +import {PageModel} from './PageModel'; +import {gestureRefresh} from './impl/GestureRefresh'; +import {page} from './impl/Page'; /** * Top-level Component within an application, responsible for rendering a stack of * pages and managing transitions between pages. */ -export const [Navigator, navigator] = hoistCmp.withFactory({ +export const [Navigator, navigator] = hoistCmp.withFactory({ displayName: 'Navigator', model: uses(NavigatorModel), className: 'xh-navigator', - - render({model, className, animation = 'slide'}) { - return swiper( - onsenNavigator({ + render({model, className}) { + const {stack, allowSlideNext, allowSlidePrev} = model; + return gestureRefresh( + swiper({ className, - initialRoute: {init: true}, - animation, - animationOptions: {duration: 0.2, delay: 0, timing: 'ease-in'}, - renderPage: model.renderPage, - onPostPush: model.onPageChange, - onPostPop: model.onPageChange + allowSlideNext, + allowSlidePrev, + slidesPerView: 1, + modules: [EffectCreative], + effect: 'creative', + creativeEffect: { + prev: { + shadow: true, + translate: ['-15%', 0, -1] + }, + next: { + translate: ['100%', 0, 0] + } + }, + onSwiper: swiper => model.setSwiper(swiper), + items: stack.map(it => { + const {key} = it as PageModel; + return swiperSlide({ + key: `slide-${key}`, + item: page({ + key: `page-${key}`, + model: it + }) + }); + }) }) ); } diff --git a/mobile/cmp/navigator/NavigatorModel.ts b/mobile/cmp/navigator/NavigatorModel.ts index 0ff89f8fb..c13d028fb 100644 --- a/mobile/cmp/navigator/NavigatorModel.ts +++ b/mobile/cmp/navigator/NavigatorModel.ts @@ -5,12 +5,14 @@ * Copyright © 2025 Extremely Heavy Industries Inc. */ import {HoistModel, RefreshMode, RenderMode, XH} from '@xh/hoist/core'; -import '@xh/hoist/mobile/register'; import {action, bindable, makeObservable} from '@xh/hoist/mobx'; -import {ensureNotEmpty, ensureUniqueBy, throwIf, warnIf, mergeDeep} from '@xh/hoist/utils/js'; +import {ensureNotEmpty, ensureUniqueBy, throwIf, mergeDeep} from '@xh/hoist/utils/js'; +import {wait} from '@xh/hoist/promise'; import {find, isEqual, keys} from 'lodash'; -import {page} from './impl/Page'; +import {Swiper} from 'swiper/types'; +import '@xh/hoist/mobile/register'; import {PageConfig, PageModel} from './PageModel'; +import {findScrollableParent, isDraggableEl} from './impl/Utils'; export interface NavigatorConfig { /** Configs for PageModels, representing all supported pages within this Navigator/App. */ @@ -22,12 +24,15 @@ export interface NavigatorConfig { */ track?: boolean; - /** True to enable 'swipe to go back' functionality. */ - swipeToGoBack?: boolean; - /** True to enable 'pull down to refresh' functionality. */ pullDownToRefresh?: boolean; + /** + * Time (in milliseconds) for the transition between pages on route change. + * Defaults to 500. + */ + transitionMs?: number; + /** * Strategy for rendering pages. Can be set per-page via `PageModel.renderMode`. * See enum for description of supported modes. @@ -42,7 +47,7 @@ export interface NavigatorConfig { } /** - * Model for handling stack-based navigation between Onsen pages. + * Model for handling stack-based navigation between pages. * Provides support for routing based navigation. */ export class NavigatorModel extends HoistModel { @@ -52,48 +57,54 @@ export class NavigatorModel extends HoistModel { stack: PageModel[] = []; pages: PageConfig[] = []; - track: boolean; - swipeToGoBack: boolean; pullDownToRefresh: boolean; + transitionMs: number; renderMode: RenderMode; refreshMode: RefreshMode; - private _navigator = null; + private _swiper: Swiper; private _callback: () => void; - private _prevKeyStack: string[]; + private _touchStartX: number; get activePageId(): string { return this.activePage?.id; } get activePage(): PageModel { - const {stack} = this; - return stack[stack.length - 1]; + return this.stack[this.activePageIdx]; + } + + get activePageIdx(): number { + return this._swiper?.activeIndex ?? 0; + } + + get allowSlideNext(): boolean { + return this.activePageIdx < this.stack.length - 1; + } + + get allowSlidePrev(): boolean { + return this.activePageIdx > 0; } constructor({ pages, track = false, - swipeToGoBack = true, pullDownToRefresh = true, + transitionMs = 500, renderMode = 'lazy', refreshMode = 'onShowLazy' }: NavigatorConfig) { super(); makeObservable(this); - warnIf( - renderMode === 'always', - "RenderMode.ALWAYS is not supported in Navigator. Pages can't exist before being mounted." - ); ensureNotEmpty(pages, 'NavigatorModel needs at least one page.'); ensureUniqueBy(pages, 'id', 'Multiple NavigatorModel PageModels have the same id.'); this.pages = pages; this.track = track; - this.swipeToGoBack = swipeToGoBack; this.pullDownToRefresh = pullDownToRefresh; + this.transitionMs = transitionMs; this.renderMode = renderMode; this.refreshMode = refreshMode; @@ -102,11 +113,6 @@ export class NavigatorModel extends HoistModel { run: () => this.onRouteChange() }); - this.addReaction({ - track: () => this.stack, - run: this.onStackChangeAsync - }); - if (track) { this.addReaction({ track: () => this.activePageId, @@ -131,8 +137,89 @@ export class NavigatorModel extends HoistModel { //-------------------- // Implementation //-------------------- - private onRouteChange(init = null) { - if (!this._navigator || !XH.routerState) return; + /** @internal */ + setSwiper(swiper: Swiper) { + if (this._swiper) return; + this._swiper = swiper; + + swiper.on('transitionEnd', () => this.onPageChange()); + + // Ensure Swiper's touch move is initially disabled, and capture + // the initial touch position. This is required to allow touch move + // to propagate to scrollable elements within the page. + swiper.on('touchStart', (s, event: PointerEvent) => { + swiper.allowTouchMove = false; + this._touchStartX = event.pageX; + }); + + // Add our own "touchmove" handler to the swiper, allowing us to toggle + // the built-in touch detection based on the presence of scrollable elements. + swiper.el.addEventListener('touchmove', (event: TouchEvent) => { + const touch = event.touches[0], + distance = touch.clientX - this._touchStartX, + direction = distance > 0 ? 'right' : 'left'; + + const scrollableParent = findScrollableParent(event, 'horizontal'); + if (scrollableParent) { + // If there is a scrollable parent we need to determine whether to allow + // the swiper or the scrollable parent to "win". + if (direction === 'left') { + // If we are scrolling "left" (i.e. "forward"), simply always prevent Swiper + // to allow internal scrolling. Our stack-based navigation does not allow + // forward navigation. + swiper.allowTouchMove = false; + } else { + // If we are scrolling "right" (i.e. "back"), we favor Swiper if the scrollable + // parent is at the leftmost start of its scroll, or if we are in the middle of + // a Swiper transition. + swiper.allowTouchMove = + swiper.progress < 1 || !isDraggableEl(scrollableParent, 'right'); + + // During the swiper transition, undo the scrollable parent's internal scroll + // to keep it static. + if (swiper.progress < 1) { + scrollableParent.scrollLeft -= distance; + } + } + } else { + // If there is no scrollable parent, simply allow the swipe to proceed. + swiper.allowTouchMove = true; + } + }); + + // Ensure Swiper's touch move is disabled after each touch completes. + swiper.on('touchEnd', () => { + swiper.allowTouchMove = false; + }); + + this.onRouteChange(true); + } + + /** @internal */ + @action + onPageChange = () => { + // 1) Clear any pages after the active page. These can be left over from a back swipe. + this.stack = this.stack.slice(0, this._swiper.activeIndex + 1); + + // 2) Sync route to match the current page stack + const newRouteName = this.stack.map(it => it.id).join('.'), + newRouteParams = mergeDeep({}, ...this.stack.map(it => it.props)); + + XH.navigate(newRouteName, newRouteParams); + + // 3) Update state according to the active page and trigger optional callback + this.disableAppRefreshButton = this.activePage?.disableAppRefreshButton; + this._callback?.(); + this._callback = null; + + // 4) Remove finished shadow components. These are created during transitions, + // and remain overlaid on the page, preventing touch events from reaching the page. + // Presumably a bug in the Swiper library. + document.querySelectorAll('.swiper-slide-shadow-creative').forEach(e => e.remove()); + }; + + private onRouteChange(init: boolean = false) { + if (!this._swiper || !XH.routerState) return; // Break the current route name into parts, and collect any params for each part. // Use meta.params to determine which params are associated with each route part. @@ -154,7 +241,6 @@ export class NavigatorModel extends HoistModel { // Loop through the route parts, rebuilding the page stack to match. const stack = []; - for (let i = 0; i < routeParts.length; i++) { const part = routeParts[i], pageModelCfg = find(this.pages, {id: part.id}); @@ -176,85 +262,56 @@ export class NavigatorModel extends HoistModel { return; } - const page = new PageModel({ - navigatorModel: this, - ...mergeDeep({}, pageModelCfg, part) - }); - - stack.push(page); + // Re-use existing PageModels where possible + const existingPageModel = this.stack[i]; + if ( + existingPageModel?.id === part.id && + isEqual(existingPageModel?.props, part.props) + ) { + stack.push(existingPageModel); + } else { + stack.push( + new PageModel({ + navigatorModel: this, + ...mergeDeep({}, pageModelCfg, part) + }) + ); + } } - this.stack = stack; - } - - private async onStackChangeAsync() { - // Sync Onsen Navigator's pages with our stack - if (!this._navigator) return; - const {stack} = this, - keyStack = stack.map(it => it.key), - prevKeyStack = this._prevKeyStack || [], - backOnePage = isEqual(keyStack, prevKeyStack.slice(0, -1)), - forwardOnePage = isEqual(keyStack.slice(0, -1), prevKeyStack); - - // Skip transition animation if the active page is going to be unmounted - let options; - if (this.activePage?.renderMode === 'unmountOnHide') { - options = {animation: 'none'}; + // Immediately set the stack if this is the initial route change + if (init) { + this.stack = stack; + this._swiper.update(); + this._swiper.activeIndex = this.stack.length - 1; + return; } - this._prevKeyStack = keyStack; + // Compare new stack to current stack to determine how to navigate + const {transitionMs} = this, + newKeyStack = stack.map(it => it.key), + currKeyStack = this.stack.map(it => it.key), + backOnePage = isEqual(newKeyStack, currKeyStack.slice(0, -1)), + forwardOnePage = isEqual(newKeyStack.slice(0, -1), currKeyStack); if (backOnePage) { - // If we have gone back one page in the same stack, we can safely pop() the page - return this._navigator.popPage(options); - } else if (forwardOnePage) { - // If we have gone forward one page in the same stack, we can safely push() the new page - return this._navigator.pushPage(stack[stack.length - 1], options); + // Don't update the stack yet. Instead, wait until after the animation has + // completed in onPageChange(). + this._swiper.slidePrev(transitionMs); } else { - // Otherwise, we should reset the page stack - return this._navigator.resetPageStack(stack, {animation: 'none'}); + // Otherwise, update the stack immediately and navigate to the new page. + this.stack = stack; + this._swiper.update(); + + // Wait for the new stack to be rendered before sliding to the new page. + wait(1).then(() => { + if (forwardOnePage) { + this._swiper.slideNext(transitionMs); + } else { + // Jump instantly to the active page. + this._swiper.slideTo(stack.length - 1, 0); + } + }); } } - - renderPage = (model, navigator) => { - const {init, key} = model; - - // Note: We use the special 'init' object to obtain a reference to the - // navigator and to read the initial route. - if (init) { - if (!this._navigator) { - this._navigator = navigator; - this.onRouteChange(init); - } - return null; - } - - // This is a workaround for an Onsen issue with resetPageStack(), - // which can result in transient duplicate pages in a stack. Having duplicate pages - // will cause React to throw with a duplicate key error. The error occurs - // when navigating from one page stack to another where the last page of - // the new stack is already present in the previous stack. - // - // For this workaround, we skip rendering the duplicate page (the one at the incorrect index). - // - // See https://github.com/OnsenUI/OnsenUI/issues/2682 - const onsenNavPages = this._navigator.routes.filter(it => !it.init), - hasDupes = onsenNavPages.filter(it => it.key === key).length > 1; - - if (hasDupes) { - const onsenIdx = onsenNavPages.indexOf(model), - ourIdx = this.stack.findIndex(it => it.key === key); - - if (onsenIdx !== ourIdx) return null; - } - - return page({model, key}); - }; - - @action - onPageChange = () => { - this.disableAppRefreshButton = this.activePage?.disableAppRefreshButton; - this._callback?.(); - this._callback = null; - }; } diff --git a/mobile/cmp/navigator/impl/swipe/Swiper.scss b/mobile/cmp/navigator/impl/GestureRefresh.scss similarity index 90% rename from mobile/cmp/navigator/impl/swipe/Swiper.scss rename to mobile/cmp/navigator/impl/GestureRefresh.scss index 31b4891a1..9b5cd5118 100644 --- a/mobile/cmp/navigator/impl/swipe/Swiper.scss +++ b/mobile/cmp/navigator/impl/GestureRefresh.scss @@ -7,7 +7,12 @@ $size: 36px; $half-size: 18px; -.xh-swiper-indicator { +.xh-gesture-refresh { + flex: 1; + width: 100%; +} + +.xh-gesture-refresh-indicator { margin: -$half-size 0 0; position: absolute; display: flex; diff --git a/mobile/cmp/navigator/impl/GestureRefresh.ts b/mobile/cmp/navigator/impl/GestureRefresh.ts new file mode 100644 index 000000000..8356ad67c --- /dev/null +++ b/mobile/cmp/navigator/impl/GestureRefresh.ts @@ -0,0 +1,55 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2025 Extremely Heavy Industries Inc. + */ +import {div, frame} from '@xh/hoist/cmp/layout'; +import {creates, hoistCmp} from '@xh/hoist/core'; +import {Icon} from '@xh/hoist/icon'; +import {gestureDetector} from '@xh/hoist/kit/onsen'; +import classNames from 'classnames'; +import './GestureRefresh.scss'; +import {GestureRefreshModel} from './GestureRefreshModel'; + +/** + * Wrap the Navigator with gesture that triggers a refresh by pulling down. + * + * @internal + */ +export const gestureRefresh = hoistCmp.factory({ + model: creates(GestureRefreshModel), + render({model, children}) { + return frame( + refreshIndicator(), + gestureDetector({ + className: 'xh-gesture-refresh', + onDragStart: model.onDragStart, + onDrag: model.onDrag, + onDragEnd: model.onDragEnd, + item: children + }) + ); + } +}); + +const refreshIndicator = hoistCmp.factory(({model}) => { + const {refreshStarted, refreshProgress, refreshCompleted} = model, + top = -40 + refreshProgress * 85, + degrees = Math.floor(refreshProgress * 360), + className = classNames( + 'xh-gesture-refresh-indicator', + refreshCompleted ? 'xh-gesture-refresh-indicator--complete' : null, + refreshStarted ? 'xh-gesture-refresh-indicator--started' : null + ); + + return div({ + className, + style: { + top, + left: '50%', + transform: `translateX(-50%) rotate(${degrees}deg)` + }, + item: Icon.refresh() + }); +}); diff --git a/mobile/cmp/navigator/impl/GestureRefreshModel.ts b/mobile/cmp/navigator/impl/GestureRefreshModel.ts new file mode 100644 index 000000000..4143c58ed --- /dev/null +++ b/mobile/cmp/navigator/impl/GestureRefreshModel.ts @@ -0,0 +1,86 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2025 Extremely Heavy Industries Inc. + */ +import {HoistModel, lookup, XH} from '@xh/hoist/core'; +import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; +import {consumeEvent} from '@xh/hoist/utils/js'; +import {isFinite, clamp} from 'lodash'; +import {NavigatorModel} from '../NavigatorModel'; +import {hasDraggableParent} from './Utils'; + +/** + * @internal + */ +export class GestureRefreshModel extends HoistModel { + @lookup(NavigatorModel) navigatorModel; + + @observable refreshProgress = null; + + @computed + get refreshStarted() { + return isFinite(this.refreshProgress); + } + + @computed + get refreshCompleted() { + return this.refreshProgress === 1; + } + + @action + refreshStart() { + this.refreshProgress = 0; + } + + @action + refreshEnd() { + this.refreshProgress = null; + } + + constructor() { + super(); + makeObservable(this); + } + + @action + onDragStart = e => { + const {navigatorModel} = this, + {direction} = e.gesture; + + this.refreshEnd(); + if ( + direction === 'down' && + navigatorModel.pullDownToRefresh && + !hasDraggableParent(e, 'down') + ) { + this.refreshStart(); + consumeEvent(e); + return; + } + }; + + @action + onDrag = e => { + const {direction, deltaY} = e.gesture; + if (this.refreshStarted) { + if (direction !== 'down') { + this.refreshEnd(); + return; + } + this.refreshProgress = clamp(deltaY / 150, 0, 1); + consumeEvent(e); + return; + } + }; + + @action + onDragEnd = e => { + if (this.refreshStarted) { + if (this.refreshCompleted) XH.refreshAppAsync(); + this.refreshEnd(); + consumeEvent(e); + } + }; +} diff --git a/mobile/cmp/navigator/impl/Page.scss b/mobile/cmp/navigator/impl/Page.scss index 31aecd0bb..97eddcf10 100644 --- a/mobile/cmp/navigator/impl/Page.scss +++ b/mobile/cmp/navigator/impl/Page.scss @@ -1,7 +1,9 @@ -.xh-page .page__content { +.xh-page { display: flex; align-items: stretch; flex-direction: column; - transform: translateX(0); + height: 100%; + width: 100%; + background: var(--xh-bg); font-family: var(--xh-font-family); } diff --git a/mobile/cmp/navigator/impl/Page.ts b/mobile/cmp/navigator/impl/Page.ts index f6da4e2ef..9b850d7cb 100644 --- a/mobile/cmp/navigator/impl/Page.ts +++ b/mobile/cmp/navigator/impl/Page.ts @@ -5,9 +5,9 @@ * Copyright © 2025 Extremely Heavy Industries Inc. */ import {hoistCmp, refreshContextView, uses} from '@xh/hoist/core'; -import {page as onsenPage} from '@xh/hoist/kit/onsen'; +import {div} from '@xh/hoist/cmp/layout'; +import {throwIf} from '@xh/hoist/utils/js'; import {elementFromContent} from '@xh/hoist/utils/react'; -import {useRef} from 'react'; import {PageModel} from '../PageModel'; import './Page.scss'; import {errorBoundary} from '@xh/hoist/cmp/error/ErrorBoundary'; @@ -26,22 +26,20 @@ export const page = hoistCmp.factory({ model: uses(PageModel, {publishMode: 'limited'}), render({model}) { - const {content, props, isActive, renderMode, refreshContextModel} = model, - wasActivated = useRef(false); + const {content, props, isActive, renderMode, refreshContextModel} = model; + throwIf( + renderMode === 'always', + "RenderMode 'always' is not supported in Navigator. Pages can't exist before being mounted." + ); - if (!wasActivated.current && isActive) wasActivated.current = true; - - if ( - !isActive && - (renderMode === 'unmountOnHide' || (renderMode === 'lazy' && !wasActivated.current)) - ) { + if (!isActive && renderMode === 'unmountOnHide') { // Note: We must render an empty placeholder page to work with the Navigator. - return onsenPage({className: 'xh-page'}); + return div({className: 'xh-page'}); } return refreshContextView({ model: refreshContextModel, - item: onsenPage({ + item: div({ className: 'xh-page', item: errorBoundary(elementFromContent(content, props)) }) diff --git a/mobile/cmp/navigator/impl/Utils.ts b/mobile/cmp/navigator/impl/Utils.ts new file mode 100644 index 000000000..936e23c8b --- /dev/null +++ b/mobile/cmp/navigator/impl/Utils.ts @@ -0,0 +1,107 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2025 Extremely Heavy Industries Inc. + */ + +/** + * @internal + */ + +//--------------------------- +// "Scrollable" in this context means styled to allow scrolling in the given axis, and it's +// internal size is larger than the container. +//--------------------------- +export function hasScrollableParent(e: TouchEvent, axis: 'horizontal' | 'vertical'): boolean { + return !!findScrollableParent(e, axis); +} + +export function findScrollableParent(e: TouchEvent, axis: 'horizontal' | 'vertical'): HTMLElement { + for (let el = e.target as HTMLElement; el && el !== document.body; el = el.parentElement) { + if (isScrollableEl(el, axis)) { + return el; + } + } + return null; +} + +export function isScrollableEl(el: HTMLElement, axis: 'horizontal' | 'vertical') { + // Don't conflict with grid header reordering or chart dragging. + if (el.classList.contains('xh-grid-header') || el.classList.contains('xh-chart')) { + return true; + } + + // Ignore Onsen "swiper" elements created by tab container (even without swiping enabled) + if (el.classList.contains('ons-swiper') || el.classList.contains('ons-swiper-target')) { + return false; + } + + const {overflowX, overflowY} = window.getComputedStyle(el); + if ( + axis === 'horizontal' && + el.scrollWidth > el.offsetWidth && + (overflowX === 'auto' || overflowX === 'scroll') + ) { + return true; + } + if ( + axis === 'vertical' && + el.scrollHeight > el.offsetHeight && + (overflowY === 'auto' || overflowY === 'scroll') + ) { + return true; + } + return false; +} + +//--------------------------- +// "Draggable" in this context means both "Scrollable" and has room to scroll in the given direction, +// i.e. it would consume a drag gesture. Open to suggestions for a better name. +//--------------------------- +export function hasDraggableParent( + e: TouchEvent, + direction: 'up' | 'right' | 'down' | 'left' +): boolean { + return !!findDraggableParent(e, direction); +} + +export function findDraggableParent( + e: TouchEvent, + direction: 'up' | 'right' | 'down' | 'left' +): HTMLElement { + // Loop through the touch targets to ensure it is safe to swipe + for (let el = e.target as HTMLElement; el && el !== document.body; el = el.parentElement) { + if (isDraggableEl(el, direction)) { + return el; + } + } + return null; +} + +export function isDraggableEl( + el: HTMLElement, + direction: 'up' | 'right' | 'down' | 'left' +): boolean { + // Don't conflict with grid header reordering or chart dragging. + if (el.classList.contains('xh-grid-header') || el.classList.contains('xh-chart')) { + return true; + } + + const axis = direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical'; + if (isScrollableEl(el, axis)) { + // Ensure any scrolling element in the target path takes priority over swipe navigation. + if (direction === 'left' && el.scrollLeft < el.scrollWidth - el.offsetWidth) { + return true; + } + if (direction === 'right' && el.scrollLeft > 0) { + return true; + } + if (direction === 'up' && el.scrollTop < el.scrollHeight - el.offsetHeight) { + return true; + } + if (direction === 'down' && el.scrollTop > 0) { + return true; + } + } +} diff --git a/mobile/cmp/navigator/impl/swipe/BackIndicator.ts b/mobile/cmp/navigator/impl/swipe/BackIndicator.ts deleted file mode 100644 index b0f7e2cfa..000000000 --- a/mobile/cmp/navigator/impl/swipe/BackIndicator.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2025 Extremely Heavy Industries Inc. - */ -import {div} from '@xh/hoist/cmp/layout'; -import {hoistCmp} from '@xh/hoist/core'; -import {Icon} from '@xh/hoist/icon'; -import classNames from 'classnames'; -import {SwiperModel} from './SwiperModel'; - -/** - * Indicator for the swipeToGoBack affordance - * @internal - */ -export const backIndicator = hoistCmp.factory(({model}) => { - const {backStarted, backProgress, backCompleted} = model, - left = -40 + backProgress * 60, - className = classNames( - 'xh-swiper-indicator', - backCompleted ? 'xh-swiper-indicator--complete' : null, - backStarted ? 'xh-swiper-indicator--started' : null - ); - return div({ - className, - style: { - top: '50%', - left, - transform: `translateY(-50%)` - }, - item: Icon.chevronLeft() - }); -}); diff --git a/mobile/cmp/navigator/impl/swipe/RefreshIndicator.ts b/mobile/cmp/navigator/impl/swipe/RefreshIndicator.ts deleted file mode 100644 index 4dc06f1ed..000000000 --- a/mobile/cmp/navigator/impl/swipe/RefreshIndicator.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2025 Extremely Heavy Industries Inc. - */ -import {div} from '@xh/hoist/cmp/layout'; -import {hoistCmp} from '@xh/hoist/core'; -import {Icon} from '@xh/hoist/icon'; -import classNames from 'classnames'; -import {SwiperModel} from './SwiperModel'; - -/** - * Indicator for the pulldownToRefresh affordance - * @internal - */ -export const refreshIndicator = hoistCmp.factory(({model}) => { - const {refreshStarted, refreshProgress, refreshCompleted} = model, - top = -40 + refreshProgress * 85, - degrees = Math.floor(refreshProgress * 360), - className = classNames( - 'xh-swiper-indicator', - refreshCompleted ? 'xh-swiper-indicator--complete' : null, - refreshStarted ? 'xh-swiper-indicator--started' : null - ); - - return div({ - className, - style: { - top, - left: '50%', - transform: `translateX(-50%) rotate(${degrees}deg)` - }, - item: Icon.refresh() - }); -}); diff --git a/mobile/cmp/navigator/impl/swipe/Swiper.ts b/mobile/cmp/navigator/impl/swipe/Swiper.ts deleted file mode 100644 index 5d560be3f..000000000 --- a/mobile/cmp/navigator/impl/swipe/Swiper.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2025 Extremely Heavy Industries Inc. - */ -import {frame} from '@xh/hoist/cmp/layout'; -import {creates, hoistCmp} from '@xh/hoist/core'; -import {gestureDetector} from '@xh/hoist/kit/onsen'; -import {backIndicator} from './BackIndicator'; -import {refreshIndicator} from './RefreshIndicator'; -import './Swiper.scss'; -import {SwiperModel} from './SwiperModel'; - -/** - * Wrap the Onsen Navigator with drag gesture handling. - * - * @internal - */ -export const swiper = hoistCmp.factory({ - model: creates(SwiperModel), - - render({model, children}) { - return frame( - refreshIndicator(), - backIndicator(), - gestureDetector({ - onDragStart: model.onDragStart, - onDrag: model.onDrag, - onDragEnd: model.onDragEnd, - item: children - }) - ); - } -}); diff --git a/mobile/cmp/navigator/impl/swipe/SwiperModel.ts b/mobile/cmp/navigator/impl/swipe/SwiperModel.ts deleted file mode 100644 index 452a5fe1f..000000000 --- a/mobile/cmp/navigator/impl/swipe/SwiperModel.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* - * This file belongs to Hoist, an application development toolkit - * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) - * - * Copyright © 2025 Extremely Heavy Industries Inc. - */ -import {HoistModel, lookup, XH} from '@xh/hoist/core'; -import {action, computed, makeObservable, observable} from '@xh/hoist/mobx'; -import {consumeEvent} from '@xh/hoist/utils/js'; -import {isFinite, clamp} from 'lodash'; -import {NavigatorModel} from '../../NavigatorModel'; - -import './Swiper.scss'; - -/** - * @internal - */ -export class SwiperModel extends HoistModel { - @lookup(NavigatorModel) navigatorModel; - - @observable backProgress = null; - @observable refreshProgress = null; - - @computed get backStarted() { - return isFinite(this.backProgress); - } - @computed get backCompleted() { - return this.backProgress === 1; - } - @action backStart() { - this.backProgress = 0; - } - @action backEnd() { - this.backProgress = null; - } - - @computed get refreshStarted() { - return isFinite(this.refreshProgress); - } - @computed get refreshCompleted() { - return this.refreshProgress === 1; - } - @action refreshStart() { - this.refreshProgress = 0; - } - @action refreshEnd() { - this.refreshProgress = null; - } - - constructor() { - super(); - makeObservable(this); - } - - @action - onDragStart = e => { - const {navigatorModel} = this, - {direction} = e.gesture; - - this.refreshEnd(); - this.backEnd(); - - // Back - // Check we have a page to nav to and avoid conflict with browser back - if ( - direction === 'right' && - navigatorModel.swipeToGoBack && - navigatorModel.stack.length >= 2 && - e.gesture.startEvent.center.pageX > 20 && - !this.isDraggingChild(e, 'right') - ) { - this.backStart(); - consumeEvent(e); - return; - } - - // Refresh - if ( - direction === 'down' && - navigatorModel.pullDownToRefresh && - !this.isDraggingChild(e, 'down') - ) { - this.refreshStart(); - consumeEvent(e); - return; - } - }; - - @action - onDrag = e => { - const {direction, deltaX, deltaY} = e.gesture; - - // For either gesture we set normalised progress based on distance dragged, or kill it - - // Back - if (this.backStarted) { - if (direction !== 'right') { - this.backEnd(); - return; - } - this.backProgress = clamp(deltaX / 150, 0, 1); - consumeEvent(e); - return; - } - - // Refresh - if (this.refreshStarted) { - if (direction !== 'down') { - this.refreshEnd(); - return; - } - this.refreshProgress = clamp(deltaY / 150, 0, 1); - consumeEvent(e); - return; - } - }; - - @action - onDragEnd = e => { - // Back - if (this.backStarted) { - if (this.backCompleted) XH.popRoute(); - this.backEnd(); - consumeEvent(e); - } - - // Refresh - if (this.refreshStarted) { - if (this.refreshCompleted) XH.refreshAppAsync(); - this.refreshEnd(); - consumeEvent(e); - } - }; - - isDraggingChild(e, dir) { - // Loop through the touch targets to ensure it is safe to swipe - for (let el = e.target; el && el !== document.body; el = el.parentNode) { - // Don't conflict with grid header reordering or chart dragging. - if (el.classList.contains('xh-grid-header') || el.classList.contains('xh-chart')) { - return true; - } - - // Ensure any scrolling element in the target path takes priority over swipe navigation. - if ( - (dir === 'right' && el.scrollWidth > el.offsetWidth && el.scrollLeft > 0) || - (dir === 'down' && el.scrollHeight > el.offsetHeight && el.scrollTop > 0) - ) { - return true; - } - } - return false; - } -} diff --git a/package.json b/package.json index 5855d82f6..66eec9872 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "semver": "~7.6.0", "short-unique-id": "~5.2.0", "store2": "~2.14.3", + "swiper": "^11.2.0", "ua-parser-js": "~1.0.2" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index a41477e48..1a7bd6512 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7720,6 +7720,11 @@ svg-tags@^1.0.0: resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" integrity sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA== +swiper@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/swiper/-/swiper-11.2.0.tgz#fbef83ab8fe165ceff82c69177a57033809cb1c2" + integrity sha512-rjjAKgDEs+grR2eQshVDCcE4KNPC7CI294nfcbV9gE8WCsLdvOYXDeZKUYevqAZZp8j5hE7kpT3dAGVKFBWlxQ== + symbol-observable@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"