diff --git a/src/core/classes/items-management/swipe-navigation-items-manager.ts b/src/core/classes/items-management/swipe-navigation-items-manager.ts index 2548b7b7d..846d4e147 100644 --- a/src/core/classes/items-management/swipe-navigation-items-manager.ts +++ b/src/core/classes/items-management/swipe-navigation-items-manager.ts @@ -39,21 +39,21 @@ export class CoreSwipeNavigationItemsManager< * Navigate to the next item. */ async navigateToNextItem(): Promise { - await this.navigateToItemBy(-1, 'back'); + await this.navigateToItemBy(1, 'forward'); } /** * Navigate to the previous item. */ async navigateToPreviousItem(): Promise { - await this.navigateToItemBy(1, 'forward'); + await this.navigateToItemBy(-1, 'back'); } /** * Has a next item. */ async hasNextItem(): Promise { - const item = await this.getItemBy(-1); + const item = await this.getItemBy(1); return !!item; } @@ -62,7 +62,7 @@ export class CoreSwipeNavigationItemsManager< * Has a previous item. */ async hasPreviousItem(): Promise { - const item = await this.getItemBy(1); + const item = await this.getItemBy(-1); return !!item; } @@ -100,13 +100,7 @@ export class CoreSwipeNavigationItemsManager< * @param animationDirection Animation direction. */ protected async navigateToItemBy(delta: number, animationDirection: 'forward' | 'back'): Promise { - let item: Item | null; - - do { - item = await this.getItemBy(delta); - - delta += delta > 0 ? 1 : -1; - } while (item && this.skipItemInSwipe(item)); + const item = await this.getItemBy(delta); if (!item) { return; @@ -122,25 +116,41 @@ export class CoreSwipeNavigationItemsManager< */ protected async getItemBy(delta: number): Promise { const items = this.getSource().getItems(); - - // Get selected item. const selectedIndex = (this.selectedItem && items?.indexOf(this.selectedItem)) ?? -1; - const nextIndex = selectedIndex + delta; - if (selectedIndex === -1 || nextIndex < 0) { + if (selectedIndex === -1 || items === null) { return null; } - // Get item by delta. - const item = items?.[nextIndex] ?? null; + const deltaStep = delta > 0 ? 1 : -1; + let nextIndex = selectedIndex; + let deltaMoved = 0; - if (!item && !this.getSource().isCompleted()) { + while (deltaMoved !== delta) { + nextIndex += deltaStep; + + if (nextIndex < 0 || nextIndex >= items.length) { + break; + } + + if (this.skipItemInSwipe(items[nextIndex])) { + continue; + } + + deltaMoved += deltaStep; + } + + if (deltaMoved === delta) { + return items[nextIndex]; + } + + if (!this.getSource().isCompleted()) { await this.getSource().load(); return this.getItemBy(delta); } - return item; + return null; } /** diff --git a/src/core/classes/tests/swipe-navigation-items-manager.test.ts b/src/core/classes/tests/swipe-navigation-items-manager.test.ts new file mode 100644 index 000000000..4603fe670 --- /dev/null +++ b/src/core/classes/tests/swipe-navigation-items-manager.test.ts @@ -0,0 +1,135 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { mock, mockSingleton } from '@/testing/utils'; +import { ActivatedRoute, ActivatedRouteSnapshot, UrlSegment } from '@angular/router'; +import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; +import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; +import { CoreNavigator } from '@services/navigator'; + +interface Item { + path: string; +} + +class StubSource extends CoreRoutedItemsManagerSource { + + stubItems: Item[]; + + constructor(stubItems: Item[] = []) { + super(); + + this.stubItems = stubItems; + } + + getItemPath(item: Item): string { + return item.path; + } + + protected async loadPageItems(): Promise<{ items: Item[] }> { + return { items: this.stubItems }; + } + +} + +class StubManager extends CoreSwipeNavigationItemsManager { + + skipItemInSwipe(item: Item): boolean { + return item.path.includes('skip'); + } + +} + +describe('CoreSwipeNavigationItemsManager', () => { + + let items: Item[]; + let currentPath: string; + let source: StubSource; + let instance: StubManager; + + beforeEach(async () => { + mockSingleton(CoreNavigator, { + navigate: jest.fn(), + getCurrentRoute: () => mock({ + snapshot: mock({ + url: [mock({ path: currentPath })], + }), + }), + }); + + items = []; + currentPath = ''; + source = new StubSource(items); + instance = new StubManager(source); + }); + + it('navigates to next item', async () => { + // Arrange. + currentPath = 'foo'; + items.push({ path: 'foo' }); + items.push({ path: 'bar' }); + + await source.load(); + + // Act. + await instance.navigateToNextItem(); + + // Assert. + expect(CoreNavigator.navigate).toHaveBeenCalledWith('../bar', { animationDirection: 'forward', params: {}, replace: true }); + }); + + it('navigates to previous item', async () => { + // Arrange. + currentPath = 'bar'; + items.push({ path: 'foo' }); + items.push({ path: 'bar' }); + + await source.load(); + + // Act. + await instance.navigateToPreviousItem(); + + // Assert. + expect(CoreNavigator.navigate).toHaveBeenCalledWith('../foo', { animationDirection: 'back', params: {}, replace: true }); + }); + + it('skips items', async () => { + // Arrange. + currentPath = 'foo'; + items.push({ path: 'foo' }); + items.push({ path: 'skip' }); + items.push({ path: 'bar' }); + + await source.load(); + + // Act. + await instance.navigateToNextItem(); + + // Assert. + expect(CoreNavigator.navigate).toHaveBeenCalledWith('../bar', { animationDirection: 'forward', params: {}, replace: true }); + }); + + it('checks items', async () => { + // Arrange. + currentPath = 'foo'; + items.push({ path: 'foo' }); + items.push({ path: 'bar' }); + + await source.load(); + + // Assert. + await expect(instance.hasNextItem()).resolves.toBe(true); + await expect(instance.hasPreviousItem()).resolves.toBe(false); + }); + +}); diff --git a/src/core/directives/swipe-navigation.ts b/src/core/directives/swipe-navigation.ts index 31caacaef..0a7bec5bb 100644 --- a/src/core/directives/swipe-navigation.ts +++ b/src/core/directives/swipe-navigation.ts @@ -14,7 +14,7 @@ import { AfterViewInit, Directive, ElementRef, Input, OnDestroy } from '@angular/core'; import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; -import { Gesture } from '@ionic/angular'; +import { Gesture, GestureDetail } from '@ionic/angular'; import { CoreScreen } from '@services/screen'; import { GestureController } from '@singletons'; @@ -65,45 +65,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { style.transform = `translateX(${ev.deltaX * SWIPE_FRICTION }px)`; }, onEnd: (ev) => { - style.transition = '.5s ease-out'; - - if (ev.deltaX > ACTIVATION_THRESHOLD) { - this.manager?.hasNextItem().then((hasNext) => { - if (hasNext) { - this.preventClickOnElement(); - - style.transform = 'translateX(100%) !important'; - this.swipeRight(); - } else { - style.transform = ''; - } - - return; - }); - - return; - } - - if (ev.deltaX < -ACTIVATION_THRESHOLD) { - this.manager?.hasPreviousItem().then((hasPrevious) => { - if (hasPrevious) { - - this.preventClickOnElement(); - - style.transform = 'translateX(-100%) !important'; - this.swipeLeft(); - } else { - style.transform = ''; - } - - return; - }); - - return; - } - - style.transform = ''; - + this.onRelease(ev); }, }); this.swipeGesture.enable(); @@ -117,7 +79,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { return; } - this.manager?.navigateToPreviousItem(); + this.manager?.navigateToNextItem(); } /** @@ -128,7 +90,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { return; } - this.manager?.navigateToNextItem(); + this.manager?.navigateToPreviousItem(); } /** @@ -155,4 +117,33 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { this.swipeGesture?.destroy(); } + /** + * Handle swipe release event. + * + * @param event Event. + */ + protected async onRelease(event: GestureDetail): Promise { + this.element.style.transition = '.5s ease-out'; + + if (event.deltaX > ACTIVATION_THRESHOLD && await this.manager?.hasPreviousItem()) { + this.preventClickOnElement(); + this.swipeRight(); + + this.element.style.transform = 'translateX(100%) !important'; + + return; + } + + if (event.deltaX < -ACTIVATION_THRESHOLD && await this.manager?.hasNextItem()) { + this.element.style.transform = 'translateX(-100%) !important'; + + this.preventClickOnElement(); + this.swipeLeft(); + + return; + } + + this.element.style.transform = ''; + } + }