From 1d54cb1c4881e7f3af61d2d9ebca45d77a5e0f50 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 2 Sep 2026 14:45:36 -0300 Subject: [PATCH] feat(image-modal): enhance swipe-to-dismiss functionality with settling animation --- .../image-modal/image-modal.component.html | 7 +++++- .../image-modal/image-modal.component.scss | 10 +++++++- .../image-modal/image-modal.component.spec.ts | 23 ++++++++++++++++++ .../image-modal/image-modal.component.ts | 24 ++++++++++++++++++- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/app/shared/components/image-modal/image-modal.component.html b/src/app/shared/components/image-modal/image-modal.component.html index 6a601db..4d6c426 100644 --- a/src/app/shared/components/image-modal/image-modal.component.html +++ b/src/app/shared/components/image-modal/image-modal.component.html @@ -1,4 +1,9 @@ -
+
{ it('dismisses with a downward swipe while the image is at its base zoom', () => { const viewport = fixture.nativeElement.querySelector('.image-modal__viewport') as HTMLElement; + const modal = fixture.nativeElement.querySelector('.image-modal') as HTMLElement; viewport.dispatchEvent(pointerEvent('pointerdown', 1, 150, 100)); viewport.dispatchEvent(pointerEvent('pointermove', 1, 155, 230)); + fixture.detectChanges(); + + expect(modal.style.transform).toBe('translate3d(0, 130px, 0)'); + expect(Number(modal.style.opacity)).toBeLessThan(1); + viewport.dispatchEvent(pointerEvent('pointerup', 1, 155, 230)); expect(modalRef.dismiss).toHaveBeenCalledWith('swipe'); }); + it('returns smoothly to its position when the swipe is too short', () => { + const viewport = fixture.nativeElement.querySelector('.image-modal__viewport') as HTMLElement; + const modal = fixture.nativeElement.querySelector('.image-modal') as HTMLElement; + + viewport.dispatchEvent(pointerEvent('pointerdown', 1, 150, 100)); + viewport.dispatchEvent(pointerEvent('pointermove', 1, 150, 160)); + fixture.detectChanges(); + expect(modal.style.transform).toBe('translate3d(0, 60px, 0)'); + + viewport.dispatchEvent(pointerEvent('pointerup', 1, 150, 160)); + fixture.detectChanges(); + + expect(modalRef.dismiss).not.toHaveBeenCalled(); + expect(modal.classList.contains('image-modal--settling')).toBe(true); + expect(modal.style.transform).toBe('translate3d(0, 0px, 0)'); + }); + it('does not dismiss with a downward gesture while the image is zoomed', () => { const element = fixture.nativeElement as HTMLElement; const viewport = element.querySelector('.image-modal__viewport') as HTMLElement; diff --git a/src/app/shared/components/image-modal/image-modal.component.ts b/src/app/shared/components/image-modal/image-modal.component.ts index d3a5e46..6ae8029 100644 --- a/src/app/shared/components/image-modal/image-modal.component.ts +++ b/src/app/shared/components/image-modal/image-modal.component.ts @@ -35,10 +35,16 @@ export class ImageModalComponent { protected readonly offsetX = signal(0); protected readonly offsetY = signal(0); protected readonly imageFailed = signal(false); + protected readonly swipeOffsetY = signal(0); + protected readonly swipeSettling = signal(false); protected readonly transform = computed( () => `translate3d(${this.offsetX()}px, ${this.offsetY()}px, 0) scale(${this.zoom()})`, ); protected readonly zoomLabel = computed(() => `${Math.round(this.zoom() * 100)}%`); + protected readonly swipeTransform = computed(() => `translate3d(0, ${this.swipeOffsetY()}px, 0)`); + protected readonly swipeOpacity = computed(() => + Math.max(0.65, 1 - (this.swipeOffsetY() / 400) * 0.35), + ); private dragStart: Point | null = null; private dragOffset: Point = { x: 0, y: 0 }; @@ -88,6 +94,7 @@ export class ImageModalComponent { this.gestureMoved = false; if (this.pointers.size === 1) { + this.swipeSettling.set(false); this.dragStart = point; this.dragOffset = { x: this.offsetX(), y: this.offsetY() }; this.swipeStart = @@ -95,6 +102,7 @@ export class ImageModalComponent { } else if (this.pointers.size === 2) { this.hadMultiplePointers = true; this.swipeStart = null; + this.swipeOffsetY.set(0); this.beginPinch(); } } @@ -125,6 +133,13 @@ export class ImageModalComponent { return; } + if (this.pointers.size === 1 && this.swipeStart) { + const deltaX = Math.abs(point.x - this.swipeStart.x); + const deltaY = point.y - this.swipeStart.y; + this.swipeOffsetY.set(deltaY > 0 && deltaY >= deltaX ? deltaY : 0); + return; + } + if (this.pointers.size === 1 && this.dragStart && this.zoom() > this.data.minZoom) { this.offsetX.set(this.dragOffset.x + point.x - this.dragStart.x); this.offsetY.set(this.dragOffset.y + point.y - this.dragStart.y); @@ -137,7 +152,10 @@ export class ImageModalComponent { const trackedPoint = this.pointers.get(event.pointerId); const endPoint = trackedPoint ? { x: event.clientX, y: event.clientY } : null; const shouldDismiss = - wasTouch && endPoint !== null && this.isSwipeDown(this.swipeStart, endPoint); + event.type === 'pointerup' && + wasTouch && + endPoint !== null && + this.isSwipeDown(this.swipeStart, endPoint); this.pointers.delete(event.pointerId); const viewport = this.viewport().nativeElement; if (viewport.hasPointerCapture?.(event.pointerId)) { @@ -169,6 +187,10 @@ export class ImageModalComponent { this.pointerDownAt = null; this.hadMultiplePointers = false; this.swipeStart = null; + if (this.swipeOffsetY() > 0) { + this.swipeSettling.set(true); + this.swipeOffsetY.set(0); + } this.clampOffset(); } }