feat(image-modal): enhance swipe-to-dismiss functionality with settling animation
This commit is contained in:
@@ -1,4 +1,9 @@
|
|||||||
<div class="image-modal">
|
<div
|
||||||
|
class="image-modal"
|
||||||
|
[class.image-modal--settling]="swipeSettling()"
|
||||||
|
[style.transform]="swipeTransform()"
|
||||||
|
[style.opacity]="swipeOpacity()"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
#viewport
|
#viewport
|
||||||
class="image-modal__viewport"
|
class="image-modal__viewport"
|
||||||
|
|||||||
@@ -12,7 +12,15 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #111111;
|
background: black;
|
||||||
|
transform-origin: center top;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-modal--settling {
|
||||||
|
transition:
|
||||||
|
transform 180ms ease-out,
|
||||||
|
opacity 180ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-modal__viewport {
|
.image-modal__viewport {
|
||||||
|
|||||||
@@ -92,14 +92,37 @@ describe('ImageModalComponent', () => {
|
|||||||
|
|
||||||
it('dismisses with a downward swipe while the image is at its base zoom', () => {
|
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 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('pointerdown', 1, 150, 100));
|
||||||
viewport.dispatchEvent(pointerEvent('pointermove', 1, 155, 230));
|
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));
|
viewport.dispatchEvent(pointerEvent('pointerup', 1, 155, 230));
|
||||||
|
|
||||||
expect(modalRef.dismiss).toHaveBeenCalledWith('swipe');
|
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', () => {
|
it('does not dismiss with a downward gesture while the image is zoomed', () => {
|
||||||
const element = fixture.nativeElement as HTMLElement;
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
const viewport = element.querySelector('.image-modal__viewport') as HTMLElement;
|
const viewport = element.querySelector('.image-modal__viewport') as HTMLElement;
|
||||||
|
|||||||
@@ -35,10 +35,16 @@ export class ImageModalComponent {
|
|||||||
protected readonly offsetX = signal(0);
|
protected readonly offsetX = signal(0);
|
||||||
protected readonly offsetY = signal(0);
|
protected readonly offsetY = signal(0);
|
||||||
protected readonly imageFailed = signal(false);
|
protected readonly imageFailed = signal(false);
|
||||||
|
protected readonly swipeOffsetY = signal(0);
|
||||||
|
protected readonly swipeSettling = signal(false);
|
||||||
protected readonly transform = computed(
|
protected readonly transform = computed(
|
||||||
() => `translate3d(${this.offsetX()}px, ${this.offsetY()}px, 0) scale(${this.zoom()})`,
|
() => `translate3d(${this.offsetX()}px, ${this.offsetY()}px, 0) scale(${this.zoom()})`,
|
||||||
);
|
);
|
||||||
protected readonly zoomLabel = computed(() => `${Math.round(this.zoom() * 100)}%`);
|
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 dragStart: Point | null = null;
|
||||||
private dragOffset: Point = { x: 0, y: 0 };
|
private dragOffset: Point = { x: 0, y: 0 };
|
||||||
@@ -88,6 +94,7 @@ export class ImageModalComponent {
|
|||||||
this.gestureMoved = false;
|
this.gestureMoved = false;
|
||||||
|
|
||||||
if (this.pointers.size === 1) {
|
if (this.pointers.size === 1) {
|
||||||
|
this.swipeSettling.set(false);
|
||||||
this.dragStart = point;
|
this.dragStart = point;
|
||||||
this.dragOffset = { x: this.offsetX(), y: this.offsetY() };
|
this.dragOffset = { x: this.offsetX(), y: this.offsetY() };
|
||||||
this.swipeStart =
|
this.swipeStart =
|
||||||
@@ -95,6 +102,7 @@ export class ImageModalComponent {
|
|||||||
} else if (this.pointers.size === 2) {
|
} else if (this.pointers.size === 2) {
|
||||||
this.hadMultiplePointers = true;
|
this.hadMultiplePointers = true;
|
||||||
this.swipeStart = null;
|
this.swipeStart = null;
|
||||||
|
this.swipeOffsetY.set(0);
|
||||||
this.beginPinch();
|
this.beginPinch();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,6 +133,13 @@ export class ImageModalComponent {
|
|||||||
return;
|
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) {
|
if (this.pointers.size === 1 && this.dragStart && this.zoom() > this.data.minZoom) {
|
||||||
this.offsetX.set(this.dragOffset.x + point.x - this.dragStart.x);
|
this.offsetX.set(this.dragOffset.x + point.x - this.dragStart.x);
|
||||||
this.offsetY.set(this.dragOffset.y + point.y - this.dragStart.y);
|
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 trackedPoint = this.pointers.get(event.pointerId);
|
||||||
const endPoint = trackedPoint ? { x: event.clientX, y: event.clientY } : null;
|
const endPoint = trackedPoint ? { x: event.clientX, y: event.clientY } : null;
|
||||||
const shouldDismiss =
|
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);
|
this.pointers.delete(event.pointerId);
|
||||||
const viewport = this.viewport().nativeElement;
|
const viewport = this.viewport().nativeElement;
|
||||||
if (viewport.hasPointerCapture?.(event.pointerId)) {
|
if (viewport.hasPointerCapture?.(event.pointerId)) {
|
||||||
@@ -169,6 +187,10 @@ export class ImageModalComponent {
|
|||||||
this.pointerDownAt = null;
|
this.pointerDownAt = null;
|
||||||
this.hadMultiplePointers = false;
|
this.hadMultiplePointers = false;
|
||||||
this.swipeStart = null;
|
this.swipeStart = null;
|
||||||
|
if (this.swipeOffsetY() > 0) {
|
||||||
|
this.swipeSettling.set(true);
|
||||||
|
this.swipeOffsetY.set(0);
|
||||||
|
}
|
||||||
this.clampOffset();
|
this.clampOffset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user