2021-02-19 19:06:28 +01:00
import React , { useCallback , useMemo , useRef } from "react" ;
import { Platform , StyleSheet , View , ViewProps } from "react-native" ;
import {
PanGestureHandler ,
PanGestureHandlerGestureEvent ,
State ,
TapGestureHandler ,
TapGestureHandlerStateChangeEvent ,
} from "react-native-gesture-handler" ;
import Reanimated , {
cancelAnimation ,
Easing ,
Extrapolate ,
interpolate ,
useAnimatedStyle ,
withSpring ,
withTiming ,
useAnimatedGestureHandler ,
useSharedValue ,
} from "react-native-reanimated" ;
import type { Camera , PhotoFile , TakePhotoOptions , TakeSnapshotOptions , VideoFile } from "react-native-vision-camera" ;
import { CAPTURE_BUTTON_SIZE , SCREEN_HEIGHT , SCREEN_WIDTH , USE_SNAPSHOT_ON_ANDROID } from "./../Constants" ;
const PAN_GESTURE_HANDLER_FAIL_X = [ - SCREEN_WIDTH , SCREEN_WIDTH ] ;
const PAN_GESTURE_HANDLER_ACTIVE_Y = [ - 2 , 2 ] ;
const IS_ANDROID = Platform . OS === "android"
const START_RECORDING_DELAY = 200 ;
interface Props extends ViewProps {
camera : React.RefObject < Camera > ;
onMediaCaptured : (
media : PhotoFile | VideoFile ,
type : "photo" | "video"
) = > void ;
cameraZoom : Reanimated.SharedValue < number > ;
flash : "off" | "on" | "auto" ;
enabled : boolean ;
setIsPressingButton : ( isPressingButton : boolean ) = > void ;
}
const _CaptureButton : React.FC < Props > = ( {
camera ,
onMediaCaptured ,
cameraZoom ,
flash ,
enabled ,
setIsPressingButton ,
style ,
. . . props
} ) : React . ReactElement = > {
const pressDownDate = useRef < Date | undefined > ( undefined ) ;
const isRecording = useRef ( false ) ;
const recordingProgress = useSharedValue ( 0 ) ;
const takePhotoOptions = useMemo < TakePhotoOptions & TakeSnapshotOptions > (
( ) = > ( {
photoCodec : "jpeg" ,
qualityPrioritization : "speed" ,
flash : flash ,
quality : 90 ,
skipMetadata : true ,
} ) ,
[ flash ]
) ;
const isPressingButton = useSharedValue ( false ) ;
//#region Camera Capture
const takePhoto = useCallback ( async ( ) = > {
try {
if ( camera . current == null ) throw new Error ( "Camera ref is null!" ) ;
// If we're on Android and flash is disabled, we can use the "snapshot" method.
// this will take a snapshot of the current SurfaceView, which results in faster image
// capture rate at the cost of greatly reduced quality.
const photoMethod =
USE_SNAPSHOT_ON_ANDROID &&
IS_ANDROID &&
takePhotoOptions . flash === "off"
? "snapshot"
: "photo" ;
console . log ( ` Taking ${ photoMethod } ... ` ) ;
const photo =
photoMethod === "snapshot"
? await camera . current . takeSnapshot ( takePhotoOptions )
: await camera . current . takePhoto ( takePhotoOptions ) ;
onMediaCaptured ( photo , "photo" ) ;
} catch ( e ) {
console . error ( 'Failed to take photo!' , e ) ;
}
} , [ camera , onMediaCaptured , takePhotoOptions ] ) ;
const onStoppedRecording = useCallback ( ( ) = > {
isRecording . current = false ;
cancelAnimation ( recordingProgress ) ;
console . log ( ` stopped recording video! ` ) ;
} , [ recordingProgress ] ) ;
const stopRecording = useCallback ( async ( ) = > {
try {
if ( camera . current == null ) throw new Error ( "Camera ref is null!" ) ;
console . log ( "calling stopRecording()..." ) ;
await camera . current . stopRecording ( ) ;
console . log ( "called stopRecording()!" ) ;
} catch ( e ) {
console . error ( ` failed to stop recording! ` , e ) ;
}
} , [ camera ] ) ;
const startRecording = useCallback ( ( ) = > {
try {
if ( camera . current == null ) throw new Error ( "Camera ref is null!" ) ;
console . log ( ` calling startRecording()... ` ) ;
camera . current . startRecording ( {
flash : flash ,
onRecordingError : ( error ) = > {
console . error ( 'Recording failed!' , error ) ;
onStoppedRecording ( ) ;
} ,
onRecordingFinished : ( video ) = > {
console . log ( ` Recording successfully finished! ${ video . path } ` ) ;
onMediaCaptured ( video , "video" ) ;
onStoppedRecording ( ) ;
} ,
} ) ;
// TODO: wait until startRecording returns to actually find out if the recording has successfully started
console . log ( ` called startRecording()! ` ) ;
isRecording . current = true ;
} catch ( e ) {
console . error ( ` failed to start recording! ` , e , "camera" ) ;
}
} , [
camera ,
flash ,
onMediaCaptured ,
onStoppedRecording ,
recordingProgress ,
stopRecording ,
] ) ;
//#endregion
//#region Tap handler
const tapHandler = useRef < TapGestureHandler > ( ) ;
const onHandlerStateChanged = useCallback (
async ( { nativeEvent : event } : TapGestureHandlerStateChangeEvent ) = > {
// This is the gesture handler for the circular "shutter" button.
// Once the finger touches the button (State.BEGAN), a photo is being taken and "capture mode" is entered. (disabled tab bar)
// Also, we set `pressDownDate` to the time of the press down event, and start a 200ms timeout. If the `pressDownDate` hasn't changed
// after the 200ms, the user is still holding down the "shutter" button. In that case, we start recording.
//
// Once the finger releases the button (State.END/FAILED/CANCELLED), we leave "capture mode" (enable tab bar) and check the `pressDownDate`,
// if `pressDownDate` was less than 200ms ago, we know that the intention of the user is to take a photo. We check the `takePhotoPromise` if
// there already is an ongoing (or already resolved) takePhoto() call (remember that we called takePhoto() when the user pressed down), and
// if yes, use that. If no, we just try calling takePhoto() again
console . debug ( ` state: ${ Object . keys ( State ) [ event . state ] } ` ) ;
switch ( event . state ) {
case State . BEGAN : {
// enter "recording mode"
recordingProgress . value = 0 ;
isPressingButton . value = true ;
const now = new Date ( ) ;
pressDownDate . current = now ;
setTimeout ( ( ) = > {
if ( pressDownDate . current === now ) {
// user is still pressing down after 200ms, so his intention is to create a video
startRecording ( ) ;
}
} , START_RECORDING_DELAY ) ;
setIsPressingButton ( true ) ;
return ;
}
case State . END :
case State . FAILED :
case State . CANCELLED : {
// exit "recording mode"
try {
if ( pressDownDate . current == null )
throw new Error ( "PressDownDate ref .current was null!" ) ;
const now = new Date ( ) ;
const diff = now . getTime ( ) - pressDownDate . current . getTime ( ) ;
pressDownDate . current = undefined ;
if ( diff < START_RECORDING_DELAY ) {
// user has released the button within 200ms, so his intention is to take a single picture.
await takePhoto ( ) ;
} else {
// user has held the button for more than 200ms, so he has been recording this entire time.
await stopRecording ( ) ;
}
} finally {
setTimeout ( ( ) = > {
isPressingButton . value = false ;
setIsPressingButton ( false ) ;
} , 500 ) ;
}
return ;
}
default :
break ;
}
} ,
[
isPressingButton ,
recordingProgress ,
setIsPressingButton ,
startRecording ,
stopRecording ,
takePhoto ,
]
) ;
//#endregion
//#region Pan handler
const panHandler = useRef < PanGestureHandler > ( ) ;
const onPanGestureEvent = useAnimatedGestureHandler <
PanGestureHandlerGestureEvent ,
{ offsetY? : number ; startY? : number }
> ( {
onStart : ( event , context ) = > {
context . startY = event . absoluteY ;
const yForFullZoom = context . startY * 0.7 ;
const offsetYForFullZoom = context . startY - yForFullZoom ;
// extrapolate [0 ... 1] zoom -> [0 ... Y_FOR_FULL_ZOOM] finger position
context . offsetY = interpolate (
Math . sqrt ( cameraZoom . value ) ,
[ 0 , 1 ] ,
[ 0 , offsetYForFullZoom ] ,
Extrapolate . CLAMP
) ;
} ,
onActive : ( event , context ) = > {
const offset = context . offsetY ? ? 0 ;
const startY = context . startY ? ? SCREEN_HEIGHT ;
const yForFullZoom = startY * 0.7 ;
const zoom = interpolate (
event . absoluteY - offset ,
[ yForFullZoom , startY ] ,
[ 1 , 0 ] ,
Extrapolate . CLAMP
) ;
cameraZoom . value = zoom * * 2 ;
} ,
} ) ;
//#endregion
const shadowStyle = useAnimatedStyle (
( ) = > ( {
transform : [
{
scale : withSpring ( isPressingButton . value ? 1.1 : 1 , {
mass : 0.5 ,
damping : 35 ,
stiffness : 300 ,
} ) ,
} ,
] ,
} ) ,
[ isPressingButton ]
) ;
const buttonStyle = useAnimatedStyle (
( ) = > ( {
opacity : withTiming ( enabled ? 1 : 0.3 , {
duration : 100 ,
easing : Easing.linear ,
} ) ,
transform : [
{
scale : withSpring (
enabled ? ( isPressingButton . value ? 1 : 0.9 ) : 0.6 ,
{
stiffness : 500 ,
damping : 300 ,
}
) ,
} ,
] ,
} ) ,
[ enabled , isPressingButton ]
) ;
return (
< TapGestureHandler
enabled = { enabled }
ref = { tapHandler }
onHandlerStateChange = { onHandlerStateChanged }
shouldCancelWhenOutside = { false }
maxDurationMs = { 99999999 } // <-- this prevents the TapGestureHandler from going to State.FAILED when the user moves his finger outside of the child view (to zoom)
simultaneousHandlers = { panHandler } >
< Reanimated.View { ...props } style = { [ buttonStyle , style ] } >
< PanGestureHandler
enabled = { enabled }
ref = { panHandler }
failOffsetX = { PAN_GESTURE_HANDLER_FAIL_X }
activeOffsetY = { PAN_GESTURE_HANDLER_ACTIVE_Y }
onGestureEvent = { onPanGestureEvent }
simultaneousHandlers = { tapHandler } >
< Reanimated.View style = { styles . flex } >
< Reanimated.View style = { [ styles . shadow , shadowStyle ] } / >
< View style = { styles . button } / >
< / Reanimated.View >
< / PanGestureHandler >
< / Reanimated.View >
< / TapGestureHandler >
) ;
} ;
export const CaptureButton = React . memo ( _CaptureButton ) ;
const styles = StyleSheet . create ( {
flex : {
flex : 1 ,
} ,
shadow : {
position : "absolute" ,
width : CAPTURE_BUTTON_SIZE ,
height : CAPTURE_BUTTON_SIZE ,
borderRadius : CAPTURE_BUTTON_SIZE / 2 ,
borderWidth : 3 ,
borderColor : "rgba(225, 48, 108, 0.7)" ,
} ,
button : {
width : CAPTURE_BUTTON_SIZE ,
height : CAPTURE_BUTTON_SIZE ,
borderRadius : CAPTURE_BUTTON_SIZE / 2 ,
borderWidth : CAPTURE_BUTTON_SIZE * 0.1 ,
borderColor : "white" ,
} ,
} ) ;