From 3aded6887d8b89bccd7c8aa31306f94336ee1123 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 16 Dec 2022 11:57:45 -0600 Subject: [PATCH] Add swipe gestures to the lightbox --- src/state/models/shell-ui.ts | 52 ++++- src/view/com/lightbox/Image.tsx | 1 - src/view/com/lightbox/Images.tsx | 35 +++ src/view/com/lightbox/Lightbox.tsx | 68 +++++- src/view/com/util/PostEmbeds.tsx | 19 +- src/view/com/util/gestures/HorzSwipe.tsx | 5 - src/view/com/util/gestures/Swipe.tsx | 232 ++++++++++++++++++++ src/view/com/util/images/AutoSizedImage.tsx | 15 +- 8 files changed, 387 insertions(+), 40 deletions(-) create mode 100644 src/view/com/lightbox/Images.tsx create mode 100644 src/view/com/util/gestures/Swipe.tsx diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index 90c6ef47..fa2e78d5 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -51,18 +51,56 @@ export class ServerInputModal { } } -export class ProfileImageLightbox { +interface LightboxModel { + canSwipeLeft: boolean + canSwipeRight: boolean + onSwipeLeft: () => void + onSwipeRight: () => void +} + +export class ProfileImageLightbox implements LightboxModel { name = 'profile-image' + canSwipeLeft = false + canSwipeRight = false constructor(public profileView: ProfileViewModel) { makeAutoObservable(this) } + onSwipeLeft() {} + onSwipeRight() {} } -export class ImageLightbox { +export class ImageLightbox implements LightboxModel { name = 'image' + canSwipeLeft = true + canSwipeRight = true constructor(public uri: string) { makeAutoObservable(this) } + onSwipeLeft() {} + onSwipeRight() {} +} + +export class ImagesLightbox implements LightboxModel { + name = 'images' + get canSwipeLeft() { + return this.index > 0 + } + get canSwipeRight() { + return this.index < this.uris.length - 1 + } + constructor(public uris: string[], public index: number) { + makeAutoObservable(this) + } + onSwipeLeft() { + if (this.canSwipeLeft) { + this.index = this.index - 1 + } + } + onSwipeRight() { + if (this.canSwipeRight) { + this.index = this.index + 1 + } + } } export interface ComposerOptsPostRef { @@ -91,7 +129,11 @@ export class ShellUiModel { | ServerInputModal | undefined isLightboxActive = false - activeLightbox: ProfileImageLightbox | ImageLightbox | undefined + activeLightbox: + | ProfileImageLightbox + | ImageLightbox + | ImagesLightbox + | undefined isComposerActive = false composerOpts: ComposerOpts | undefined @@ -123,7 +165,9 @@ export class ShellUiModel { this.activeModal = undefined } - openLightbox(lightbox: ProfileImageLightbox | ImageLightbox) { + openLightbox( + lightbox: ProfileImageLightbox | ImageLightbox | ImagesLightbox, + ) { this.isLightboxActive = true this.activeLightbox = lightbox } diff --git a/src/view/com/lightbox/Image.tsx b/src/view/com/lightbox/Image.tsx index 2e330777..a620e949 100644 --- a/src/view/com/lightbox/Image.tsx +++ b/src/view/com/lightbox/Image.tsx @@ -4,7 +4,6 @@ import {Image, StyleSheet, useWindowDimensions, View} from 'react-native' export function Component({uri}: {uri: string}) { const winDim = useWindowDimensions() const top = winDim.height / 2 - (winDim.width - 40) / 2 - 100 - console.log(uri) return ( diff --git a/src/view/com/lightbox/Images.tsx b/src/view/com/lightbox/Images.tsx new file mode 100644 index 00000000..6f84dfe7 --- /dev/null +++ b/src/view/com/lightbox/Images.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import {Image, StyleSheet, useWindowDimensions, View} from 'react-native' + +export function Component({uris, index}: {uris: string[]; index: number}) { + const winDim = useWindowDimensions() + const left = index * winDim.width * -1 + return ( + + {uris.map((uri, i) => ( + + ))} + + ) +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + }, + image: { + position: 'absolute', + top: 200, + left: 0, + resizeMode: 'contain', + width: '100%', + aspectRatio: 1, + }, +}) diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 198f2039..f6c89b69 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -1,20 +1,41 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import { + Animated, + StyleSheet, + TouchableWithoutFeedback, + useWindowDimensions, + View, +} from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Swipe, Dir} from '../util/gestures/Swipe' import {useStores} from '../../../state' +import {useAnimatedValue} from '../../lib/useAnimatedValue' import * as models from '../../../state/models/shell-ui' import * as ProfileImageLightbox from './ProfileImage' import * as ImageLightbox from './Image' +import * as ImagesLightbox from './Images' export const Lightbox = observer(function Lightbox() { const store = useStores() + const winDim = useWindowDimensions() + const panX = useAnimatedValue(0) + const panY = useAnimatedValue(0) const onClose = () => { store.shell.closeLightbox() } + const onSwipeEnd = (dir: Dir) => { + if (dir === Dir.Up || dir === Dir.Down) { + onClose() + } else if (dir === Dir.Left) { + store.shell.activeLightbox?.onSwipeLeft() + } else if (dir === Dir.Right) { + store.shell.activeLightbox?.onSwipeRight() + } + } if (!store.shell.isLightboxActive) { return @@ -33,18 +54,49 @@ export const Lightbox = observer(function Lightbox() { {...(store.shell.activeLightbox as models.ImageLightbox)} /> ) + } else if (store.shell.activeLightbox?.name === 'images') { + element = ( + + ) } else { return } + const translateX = Animated.multiply(panX, winDim.width * -1) + const translateY = Animated.multiply(panY, winDim.height * -1) + const swipeTransform = {transform: [{translateX}, {translateY}]} + const swipeOpacity = { + opacity: panY.interpolate({ + inputRange: [-1, 0, 1], + outputRange: [0, 1, 0], + }), + } + return ( - <> - - - - - {element} - + + + + + + + + + + + {element} + + ) }) diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds.tsx index 5e886409..ae9212a7 100644 --- a/src/view/com/util/PostEmbeds.tsx +++ b/src/view/com/util/PostEmbeds.tsx @@ -1,25 +1,19 @@ -import React, {useEffect, useState} from 'react' +import React from 'react' import { - ActivityIndicator, - Image, ImageStyle, StyleSheet, StyleProp, Text, - TouchableWithoutFeedback, View, ViewStyle, } from 'react-native' -import { - Record as PostRecord, - Entity, -} from '../../../third-party/api/src/client/types/app/bsky/feed/post' import * as AppBskyEmbedImages from '../../../third-party/api/src/client/types/app/bsky/embed/images' import * as AppBskyEmbedExternal from '../../../third-party/api/src/client/types/app/bsky/embed/external' import {Link} from '../util/Link' -import {LinkMeta, getLikelyType, LikelyType} from '../../../lib/link-meta' import {colors} from '../../lib/styles' import {AutoSizedImage} from './images/AutoSizedImage' +import {ImagesLightbox} from '../../../state/models/shell-ui' +import {useStores} from '../../../state' type Embed = | AppBskyEmbedImages.Presented @@ -33,14 +27,19 @@ export function PostEmbeds({ embed?: Embed style?: StyleProp }) { + const store = useStores() if (embed?.$type === 'app.bsky.embed.images#presented') { const imgEmbed = embed as AppBskyEmbedImages.Presented if (imgEmbed.images.length > 0) { + const uris = imgEmbed.images.map(img => img.fullsize) + const openLightbox = (index: number) => { + store.shell.openLightbox(new ImagesLightbox(uris, index)) + } const Thumb = ({i, style}: {i: number; style: StyleProp}) => ( openLightbox(i)} /> ) if (imgEmbed.images.length === 4) { diff --git a/src/view/com/util/gestures/HorzSwipe.tsx b/src/view/com/util/gestures/HorzSwipe.tsx index 8caa3dea..6dcdcf91 100644 --- a/src/view/com/util/gestures/HorzSwipe.tsx +++ b/src/view/com/util/gestures/HorzSwipe.tsx @@ -72,11 +72,6 @@ export function HorzSwipe({ setDir(0) onSwipeStart?.() - // TODO - // if (keyboardDismissMode === 'on-drag') { - // Keyboard.dismiss() - // } - panX.stopAnimation() // @ts-expect-error: _value is private, but docs use it as well panX.setOffset(panX._value) diff --git a/src/view/com/util/gestures/Swipe.tsx b/src/view/com/util/gestures/Swipe.tsx new file mode 100644 index 00000000..f6d600d0 --- /dev/null +++ b/src/view/com/util/gestures/Swipe.tsx @@ -0,0 +1,232 @@ +import React, {useState} from 'react' +import { + Animated, + GestureResponderEvent, + I18nManager, + PanResponder, + PanResponderGestureState, + useWindowDimensions, + View, +} from 'react-native' +import {clamp} from 'lodash' + +export enum Dir { + None, + Up, + Down, + Left, + Right, +} + +interface Props { + panX: Animated.Value + panY: Animated.Value + canSwipeLeft?: boolean + canSwipeRight?: boolean + canSwipeUp?: boolean + canSwipeDown?: boolean + swipeEnabled?: boolean + hasPriority?: boolean // if has priority, will not release control of the gesture to another gesture + horzDistThresholdDivisor?: number + vertDistThresholdDivisor?: number + useNativeDriver?: boolean + onSwipeStart?: () => void + onSwipeStartDirection?: (dir: Dir) => void + onSwipeEnd?: (dir: Dir) => void + children: React.ReactNode +} + +export function Swipe({ + panX, + panY, + canSwipeLeft = false, + canSwipeRight = false, + canSwipeUp = false, + canSwipeDown = false, + swipeEnabled = true, + hasPriority = false, + horzDistThresholdDivisor = 1.75, + vertDistThresholdDivisor = 1.75, + useNativeDriver = false, + onSwipeStart, + onSwipeStartDirection, + onSwipeEnd, + children, +}: Props) { + const winDim = useWindowDimensions() + const [dir, setDir] = useState(Dir.None) + + const swipeVelocityThreshold = 35 + const swipeHorzDistanceThreshold = winDim.width / horzDistThresholdDivisor + const swipeVertDistanceThreshold = winDim.height / vertDistThresholdDivisor + + const isMovingHorizontally = ( + _: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => { + return ( + Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 1.25) && + Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 1.25) + ) + } + const isMovingVertically = ( + _: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => { + return ( + Math.abs(gestureState.dy) > Math.abs(gestureState.dx * 1.25) && + Math.abs(gestureState.vy) > Math.abs(gestureState.vx * 1.25) + ) + } + + const canDir = (d: Dir) => { + if (d === Dir.Left) return canSwipeLeft + if (d === Dir.Right) return canSwipeRight + if (d === Dir.Up) return canSwipeUp + if (d === Dir.Down) return canSwipeDown + return false + } + const isHorz = (d: Dir) => d === Dir.Left || d === Dir.Right + const isVert = (d: Dir) => d === Dir.Up || d === Dir.Down + + const canMoveScreen = ( + event: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => { + if (swipeEnabled === false) { + return false + } + + const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx + const dy = gestureState.dy + const willHandle = + (isMovingHorizontally(event, gestureState) && + ((dx > 0 && canSwipeLeft) || (dx < 0 && canSwipeRight))) || + (isMovingVertically(event, gestureState) && + ((dy > 0 && canSwipeUp) || (dy < 0 && canSwipeDown))) + return willHandle + } + + const startGesture = () => { + setDir(Dir.None) + onSwipeStart?.() + + panX.stopAnimation() + // @ts-expect-error: _value is private, but docs use it as well + panX.setOffset(panX._value) + panY.stopAnimation() + // @ts-expect-error: _value is private, but docs use it as well + panY.setOffset(panY._value) + } + + const respondToGesture = ( + _: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => { + const dx = I18nManager.isRTL ? -gestureState.dx : gestureState.dx + const dy = gestureState.dy + + let newDir = Dir.None + if (dir === Dir.None) { + // establish if the user is swiping horz or vert + if (Math.abs(dx) > Math.abs(dy)) { + newDir = dx > 0 ? Dir.Left : Dir.Right + } else { + newDir = dy > 0 ? Dir.Up : Dir.Down + } + } else if (isHorz(dir)) { + // direction update + newDir = dx > 0 ? Dir.Left : Dir.Right + } else if (isVert(dir)) { + // direction update + newDir = dy > 0 ? Dir.Up : Dir.Down + } + + if (isHorz(newDir)) { + panX.setValue( + clamp( + dx / swipeHorzDistanceThreshold, + canSwipeRight ? -1 : 0, + canSwipeLeft ? 1 : 0, + ) * -1, + ) + panY.setValue(0) + } else if (isVert(newDir)) { + panY.setValue( + clamp( + dy / swipeVertDistanceThreshold, + canSwipeDown ? -1 : 0, + canSwipeUp ? 1 : 0, + ) * -1, + ) + panX.setValue(0) + } + + if (!canDir(newDir)) { + newDir = Dir.None + } + if (newDir !== dir) { + setDir(newDir) + onSwipeStartDirection?.(newDir) + } + } + + const finishGesture = ( + _: GestureResponderEvent, + gestureState: PanResponderGestureState, + ) => { + const finish = (finalDir: dir) => () => { + if (finalDir !== Dir.None) { + onSwipeEnd?.(finalDir) + } + setDir(Dir.None) + panX.flattenOffset() + panX.setValue(0) + panY.flattenOffset() + panY.setValue(0) + } + if ( + isHorz(dir) && + (Math.abs(gestureState.dx) > swipeHorzDistanceThreshold / 4 || + Math.abs(gestureState.vx) > swipeVelocityThreshold) + ) { + Animated.timing(panX, { + toValue: dir === Dir.Left ? -1 : 1, + duration: 100, + useNativeDriver, + }).start(finish(dir)) + } else if ( + isVert(dir) && + (Math.abs(gestureState.dy) > swipeVertDistanceThreshold / 8 || + Math.abs(gestureState.vy) > swipeVelocityThreshold) + ) { + Animated.timing(panY, { + toValue: dir === Dir.Up ? -1 : 1, + duration: 100, + useNativeDriver, + }).start(finish(dir)) + } else { + onSwipeEnd?.(Dir.None) + Animated.timing(panX, { + toValue: 0, + duration: 100, + useNativeDriver, + }).start(finish(Dir.None)) + } + } + + const panResponder = PanResponder.create({ + onMoveShouldSetPanResponder: canMoveScreen, + onPanResponderGrant: startGesture, + onPanResponderMove: respondToGesture, + onPanResponderTerminate: finishGesture, + onPanResponderRelease: finishGesture, + onPanResponderTerminationRequest: () => !hasPriority, + }) + + return ( + + {children} + + ) +} diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 80cd0fa9..4728f42d 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -9,8 +9,6 @@ import { TouchableWithoutFeedback, View, } from 'react-native' -import {ImageLightbox} from '../../../../state/models/shell-ui' -import {useStores} from '../../../../state' import {colors} from '../../../lib/styles' const MAX_HEIGHT = 300 @@ -22,14 +20,13 @@ interface Dim { export function AutoSizedImage({ uri, - fullSizeUri, + onPress, style, }: { uri: string - fullSizeUri?: string + onPress?: () => void style: StyleProp }) { - const store = useStores() const [error, setError] = useState() const [imgInfo, setImgInfo] = useState() const [containerInfo, setContainerInfo] = useState() @@ -74,15 +71,9 @@ export function AutoSizedImage({ }) } - const onPressImage = () => { - if (fullSizeUri) { - store.shell.openLightbox(new ImageLightbox(fullSizeUri)) - } - } - return ( - + {error ? ( {error}