Merge pull request #3132 from NoelDeMartin/MOBILE-3926
MOBILE-3926 core: Fix swipe navigation
This commit is contained in:
		
						commit
						497cb52f61
					
				| @ -39,21 +39,21 @@ export class CoreSwipeNavigationItemsManager< | |||||||
|      * Navigate to the next item. |      * Navigate to the next item. | ||||||
|      */ |      */ | ||||||
|     async navigateToNextItem(): Promise<void> { |     async navigateToNextItem(): Promise<void> { | ||||||
|         await this.navigateToItemBy(-1, 'back'); |         await this.navigateToItemBy(1, 'forward'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Navigate to the previous item. |      * Navigate to the previous item. | ||||||
|      */ |      */ | ||||||
|     async navigateToPreviousItem(): Promise<void> { |     async navigateToPreviousItem(): Promise<void> { | ||||||
|         await this.navigateToItemBy(1, 'forward'); |         await this.navigateToItemBy(-1, 'back'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Has a next item. |      * Has a next item. | ||||||
|      */ |      */ | ||||||
|     async hasNextItem(): Promise<boolean> { |     async hasNextItem(): Promise<boolean> { | ||||||
|         const item = await this.getItemBy(-1); |         const item = await this.getItemBy(1); | ||||||
| 
 | 
 | ||||||
|         return !!item; |         return !!item; | ||||||
|     } |     } | ||||||
| @ -62,7 +62,7 @@ export class CoreSwipeNavigationItemsManager< | |||||||
|      * Has a previous item. |      * Has a previous item. | ||||||
|      */ |      */ | ||||||
|     async hasPreviousItem(): Promise<boolean> { |     async hasPreviousItem(): Promise<boolean> { | ||||||
|         const item = await this.getItemBy(1); |         const item = await this.getItemBy(-1); | ||||||
| 
 | 
 | ||||||
|         return !!item; |         return !!item; | ||||||
|     } |     } | ||||||
| @ -100,13 +100,7 @@ export class CoreSwipeNavigationItemsManager< | |||||||
|      * @param animationDirection Animation direction. |      * @param animationDirection Animation direction. | ||||||
|      */ |      */ | ||||||
|     protected async navigateToItemBy(delta: number, animationDirection: 'forward' | 'back'): Promise<void> { |     protected async navigateToItemBy(delta: number, animationDirection: 'forward' | 'back'): Promise<void> { | ||||||
|         let item: Item | null; |         const item = await this.getItemBy(delta); | ||||||
| 
 |  | ||||||
|         do { |  | ||||||
|             item = await this.getItemBy(delta); |  | ||||||
| 
 |  | ||||||
|             delta += delta > 0 ? 1 : -1; |  | ||||||
|         } while (item && this.skipItemInSwipe(item)); |  | ||||||
| 
 | 
 | ||||||
|         if (!item) { |         if (!item) { | ||||||
|             return; |             return; | ||||||
| @ -122,25 +116,41 @@ export class CoreSwipeNavigationItemsManager< | |||||||
|      */ |      */ | ||||||
|     protected async getItemBy(delta: number): Promise<Item | null> { |     protected async getItemBy(delta: number): Promise<Item | null> { | ||||||
|         const items = this.getSource().getItems(); |         const items = this.getSource().getItems(); | ||||||
| 
 |  | ||||||
|         // Get selected item.
 |  | ||||||
|         const selectedIndex = (this.selectedItem && items?.indexOf(this.selectedItem)) ?? -1; |         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; |             return null; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // Get item by delta.
 |         const deltaStep = delta > 0 ? 1 : -1; | ||||||
|         const item = items?.[nextIndex] ?? null; |         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(); |             await this.getSource().load(); | ||||||
| 
 | 
 | ||||||
|             return this.getItemBy(delta); |             return this.getItemBy(delta); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         return item; |         return null; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | |||||||
							
								
								
									
										135
									
								
								src/core/classes/tests/swipe-navigation-items-manager.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								src/core/classes/tests/swipe-navigation-items-manager.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<Item> { | ||||||
|  | 
 | ||||||
|  |     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<ActivatedRoute>({ | ||||||
|  |                 snapshot: mock<ActivatedRouteSnapshot>({ | ||||||
|  |                     url: [mock<UrlSegment>({ 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); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | }); | ||||||
| @ -14,7 +14,7 @@ | |||||||
| 
 | 
 | ||||||
| import {  AfterViewInit, Directive, ElementRef, Input, OnDestroy } from '@angular/core'; | import {  AfterViewInit, Directive, ElementRef, Input, OnDestroy } from '@angular/core'; | ||||||
| import { CoreSwipeNavigationItemsManager } from '@classes/items-management/swipe-navigation-items-manager'; | 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 { CoreScreen } from '@services/screen'; | ||||||
| import { GestureController } from '@singletons'; | import { GestureController } from '@singletons'; | ||||||
| 
 | 
 | ||||||
| @ -65,45 +65,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { | |||||||
|                 style.transform = `translateX(${ev.deltaX * SWIPE_FRICTION }px)`; |                 style.transform = `translateX(${ev.deltaX * SWIPE_FRICTION }px)`; | ||||||
|             }, |             }, | ||||||
|             onEnd: (ev) => { |             onEnd: (ev) => { | ||||||
|                 style.transition = '.5s ease-out'; |                 this.onRelease(ev); | ||||||
| 
 |  | ||||||
|                 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.swipeGesture.enable(); |         this.swipeGesture.enable(); | ||||||
| @ -117,7 +79,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         this.manager?.navigateToPreviousItem(); |         this.manager?.navigateToNextItem(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -128,7 +90,7 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         this.manager?.navigateToNextItem(); |         this.manager?.navigateToPreviousItem(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -155,4 +117,33 @@ export class CoreSwipeNavigationDirective implements AfterViewInit, OnDestroy { | |||||||
|         this.swipeGesture?.destroy(); |         this.swipeGesture?.destroy(); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * Handle swipe release event. | ||||||
|  |      * | ||||||
|  |      * @param event Event. | ||||||
|  |      */ | ||||||
|  |     protected async onRelease(event: GestureDetail): Promise<void> { | ||||||
|  |         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 = ''; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
| } | } | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user