Add swipe gestures to the lightbox
parent
3a44a1cfdc
commit
3aded6887d
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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}} />
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue