Add swipe gestures to the lightbox

zio/stable
Paul Frazee 2022-12-16 11:57:45 -06:00
parent 3a44a1cfdc
commit 3aded6887d
8 changed files with 387 additions and 40 deletions

View File

@ -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
}

View File

@ -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 (
<View style={[styles.container, {top}]}>
<Image style={styles.image} source={{uri}} />

View File

@ -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 (
<View style={[styles.container, {left}]}>
{uris.map((uri, i) => (
<Image
key={i}
style={[styles.image, {left: i * winDim.width}]}
source={{uri}}
/>
))}
</View>
)
}
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,
},
})

View File

@ -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 <View />
@ -33,18 +54,49 @@ export const Lightbox = observer(function Lightbox() {
{...(store.shell.activeLightbox as models.ImageLightbox)}
/>
)
} else if (store.shell.activeLightbox?.name === 'images') {
element = (
<ImagesLightbox.Component
{...(store.shell.activeLightbox as models.ImagesLightbox)}
/>
)
} else {
return <View />
}
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 (
<>
<TouchableOpacity style={styles.bg} onPress={onClose} />
<TouchableOpacity style={styles.xIcon} onPress={onClose}>
<FontAwesomeIcon icon="x" size={24} style={{color: '#fff'}} />
</TouchableOpacity>
{element}
</>
<View style={StyleSheet.absoluteFill}>
<Swipe
panX={panX}
panY={panY}
swipeEnabled
canSwipeLeft={store.shell.activeLightbox.canSwipeLeft}
canSwipeRight={store.shell.activeLightbox.canSwipeRight}
canSwipeUp
canSwipeDown
hasPriority
onSwipeEnd={onSwipeEnd}>
<TouchableWithoutFeedback onPress={onClose}>
<Animated.View style={[styles.bg, swipeOpacity]} />
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.xIcon}>
<FontAwesomeIcon icon="x" size={24} style={{color: '#fff'}} />
</View>
</TouchableWithoutFeedback>
<Animated.View style={swipeTransform}>{element}</Animated.View>
</Swipe>
</View>
)
})

View File

@ -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<ViewStyle>
}) {
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<ImageStyle>}) => (
<AutoSizedImage
style={style}
uri={imgEmbed.images[i].thumb}
fullSizeUri={imgEmbed.images[i].fullsize}
onPress={() => openLightbox(i)}
/>
)
if (imgEmbed.images.length === 4) {

View File

@ -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)

View File

@ -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>(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 (
<View {...panResponder.panHandlers} style={{flex: 1}}>
{children}
</View>
)
}

View File

@ -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<ImageStyle>
}) {
const store = useStores()
const [error, setError] = useState<string | undefined>()
const [imgInfo, setImgInfo] = useState<Dim | undefined>()
const [containerInfo, setContainerInfo] = useState<Dim | undefined>()
@ -74,15 +71,9 @@ export function AutoSizedImage({
})
}
const onPressImage = () => {
if (fullSizeUri) {
store.shell.openLightbox(new ImageLightbox(fullSizeUri))
}
}
return (
<View style={style}>
<TouchableWithoutFeedback onPress={onPressImage}>
<TouchableWithoutFeedback onPress={onPress}>
{error ? (
<View style={[styles.container, styles.errorContainer]}>
<Text style={styles.error}>{error}</Text>