RN Perspective Crop - OpenCV편에서 만든 WebView는 이미지 처리 연산만 담당할 뿐 사용자에게 보이지 않습니다.
하지만 내부의 캔버스는 크기를 가지고 있습니다. 윈도우에서 브라우저 크기를 조절해도 스크롤바만 생기고 내부 컨텐츠 크기는 그대로인 것과 동일한 개념입니다.
이제 아래와 같이 사용자가 크롭할 영역을 선택할 수 있는 컴포넌트인 Perspective Cropper를 만들어보겠습니다.

주요 재료
크기가 없고, 자식 컴포넌트를 터치하고 드래그했을 때, 움직인 거리 정보를 포함한 이벤트를 발생시키는 PanGestureHandler 컴포넌트를 제공해 줍니다.
리액트 네이티브에서 기본적으로 제공되는 애니메이션 라이브러리인 Animated는 느립니다.
한 두개 컴포넌트를 Animated로 만드는 것은 차이가 없을지 모르겠지만, 위의 gif처럼 만들기 위해서 9개의 컴포넌트가 필요하고, 사용자의 터치 동작에 의해 최소 4개의 컴포넌트가 새로운 위치를 계산하고 그곳으로 이동해야 합니다.
이 라이브러리는 리액트 네이티브 자바스크립트 엔진 외부에 별도의 자바스크립트 엔진을 새로 만들고, 여기서 애니메이션 동작을 UI 스레드와 동기적으로 처리한다고 합니다.
그리고 만들어질 때부터 react-native-gesture-handler와 연계하는 것을 가정했기 때문에, react-native-gesture-handler에 포함된 컴포넌트와 연동하기도 쉽습니다.
사각형을 그리고, 선택되지 않은 영역을 어둡게 처리하기 위해 사용됩니다.
react-native-reanimated의 Animated.createAnimatedComponent를 사용해서 Animated 컴포넌트로 래핑해서 사용합니다.
import Animated from 'react-native-reanimated' | |
import { Svg, Path } from 'react-native-svg' | |
const AnimatedSvg = Animated.createAnimatedComponent(Svg); | |
const AnimatedPath = Animated.createAnimatedComponent(Path); |
설계
주요 기능만 다이어그램으로 그리면 아래와 같습니다.

Perspective Cropper
직접 만든 컴포넌트인 Cropper와 기본 컴포넌트인 Image 두 개가 겹쳐 있는 상태입니다.
Image는 가로, 세로 100%이고, resizedMode가 contain이기 때문에, 이미지는 원본 비율로 Image 컴포넌트 안에서 꽉 차게 됩니다.
그리고 onImageLoad에서 얻은 이미지의 가로, 세로(state.imageWidth/imageHeight) 비율과 컨테이너의 가로, 세로(state.viewWidth/viewHeight) 비율을 비교해서 화면에 보이는 이미지의 가로, 세로 크기(state.imageWidth/imageHeight)를 구합니다. 이것을 Cropper에 전달합니다.
import React, { Component } from "react" | |
import { Image, ImageLoadEventData, LayoutChangeEvent, NativeSyntheticEvent, View, ViewStyle } from "react-native" | |
import Cropper from "./Cropper"; | |
import { OpenCVContext, OpenCVContextProps } from "../../../Contexts/OpenCVContext"; | |
interface PerspectiveCropperProps { | |
source: {uri: string}; | |
style?: ViewStyle; | |
} | |
// Cropper에서 좌표 가중치를 계산하기 위해 image~와 imageLayout~을 상태로 만들어 줄 필요가 있다. | |
interface State { | |
didLayout: boolean; | |
imageLoaded: boolean; | |
viewWidth: number; | |
viewHeight: number; | |
imageWidth: number; | |
imageHeight: number; | |
resizedImageWidth: number; | |
resizedImageHeight: number; | |
uri: string; | |
} | |
export interface Point { | |
x: number; | |
y: number | |
} | |
interface Points { | |
p1: Point, | |
p2: Point, | |
p3: Point, | |
p4: Point | |
} | |
export default class PersPectiveCropper extends Component<PerspectiveCropperProps, State> { | |
static contextType = OpenCVContext; | |
opencvContext: OpenCVContextProps | undefined = undefined; | |
private async loadImageToOpenCV(uri: string, imageWidth: number, imageHeight: number) { | |
if(!this.opencvContext) return; | |
const {imageLoad} = this.opencvContext; | |
imageLoad(uri, imageWidth, imageHeight); | |
} | |
points: Points = { | |
p1: {x: 0, y: 0}, | |
p2: {x: 0, y: 0}, | |
p3: {x: 0, y: 0}, | |
p4: {x: 0, y: 0}, | |
} | |
constructor(props: PerspectiveCropperProps, context: OpenCVContextProps) { | |
super(props); | |
this.opencvContext = context; | |
const state: State = { | |
didLayout: false, | |
imageLoaded: false, | |
viewWidth: 0, | |
viewHeight: 0, | |
imageWidth: 0, | |
imageHeight: 0, | |
resizedImageWidth: 0, | |
resizedImageHeight: 0, | |
uri: this.props.source.uri | |
} | |
this.state = state; | |
this.onImageLoad = this.onImageLoad.bind(this); | |
this.onLayout = this.onLayout.bind(this); | |
this.setP1 = this.setP1.bind(this); | |
this.setP2 = this.setP2.bind(this); | |
this.setP3 = this.setP3.bind(this); | |
this.setP4 = this.setP4.bind(this); | |
this.setPoints = [this.setP1, this.setP2, this.setP3, this.setP4]; | |
} | |
// onLayout보다 먼저 발생했다 ... 언제나? | |
onImageLoad(event: NativeSyntheticEvent<ImageLoadEventData>) { | |
const {width: imageWidth, height: imageHeight} = event.nativeEvent.source; // 이미지의 실제 너비, 높이 | |
this.loadImageToOpenCV(this.state.uri, imageWidth, imageHeight); | |
const {didLayout, viewWidth, viewHeight} = this.state; | |
// onLayout보다 먼저 발생한 경우, (일반적) | |
if(!didLayout) { | |
this.setState({ | |
...this.state, | |
imageLoaded: true, | |
imageWidth, | |
imageHeight | |
}); | |
return; | |
} | |
// onImageLoad가 더 늦게 발생한 경우, (외부에서 이미지를 변경한 경우) | |
const viewRatio = viewWidth / viewHeight; // FastImage의 비율 | |
const imageRatio = imageWidth / imageHeight; // 이미지의 비율 | |
const isWidthFull = imageRatio > viewRatio; | |
// FastImage 안의 이미지 높이, 너비 결정 | |
const resizedImageWidth = isWidthFull ? viewWidth : (viewHeight * imageRatio); | |
const resizedImageHeight = isWidthFull ? (viewWidth / imageRatio) : viewHeight; | |
// console.log("[onImageLoad] 이미지 가로 풀? ", isWidthFull); | |
this.setState({ | |
...this.state, | |
imageLoaded: true, | |
imageWidth, | |
imageHeight, | |
resizedImageWidth, | |
resizedImageHeight | |
}); | |
} | |
onLayout(event: LayoutChangeEvent) { | |
// FastImage의 높이, 너비 | |
const {width: viewWidth, height: viewHeight} = event.nativeEvent.layout; | |
const {imageWidth, imageHeight, imageLoaded} = this.state; | |
if(!imageLoaded) { | |
this.setState({ | |
...this.state, | |
didLayout: true, | |
viewWidth, | |
viewHeight | |
}) | |
return | |
} | |
// 이미지 로드 후 레이아웃 이벤트 발생 (일반적인 경우) | |
const viewRatio = viewWidth / viewHeight; // FastImage의 비율 | |
const imageRatio = imageWidth / imageHeight; // 실제 이미지의 비율 | |
const isWidthFull = imageRatio > viewRatio; | |
const resizedImageWidth = isWidthFull ? viewWidth : viewHeight * imageRatio; | |
const resizedImageHeight = isWidthFull ? viewWidth / imageRatio : viewHeight; | |
// console.log("[onLayout] 이미지 가로 풀? ", isWidthFull); | |
// 이미지는 이미 로드됐기 때문에, imageWidth와 imageHeight는 최신 상태이다. | |
this.setState({ | |
...this.state, | |
didLayout: true, | |
viewWidth, | |
viewHeight, | |
resizedImageWidth, | |
resizedImageHeight | |
}); | |
} | |
setP1(point: Point) { this.points.p1 = point; } | |
setP2(point: Point) { this.points.p2 = point; } | |
setP3(point: Point) { this.points.p3 = point; } | |
setP4(point: Point) { this.points.p4 = point; } | |
setPoints: ((point: Point) => void)[] = []; | |
// 크롭이 완료되고, 이미지가 리랜더링되기 전에 다시 크롭하면 오류가 발생한다. 외부에서 차단해야 한다. | |
crop() { | |
if(!this.opencvContext) return; | |
const {crop} = this.opencvContext; | |
crop(this.points); | |
} | |
componentDidUpdate(_: PerspectiveCropperProps, prevState: State){ | |
const newUri = this.props.source.uri; | |
if(newUri !== prevState.uri) { | |
// uri 바뀌면 이미지 다시 로드되면서 onImageLoad 실행 => loadImageToOpenCV | |
this.setState({ | |
...this.state, | |
uri: newUri | |
}); | |
} | |
} | |
componentWillUnmount() { | |
if(!this.opencvContext) return; | |
const {imageUnload} = this.opencvContext; | |
imageUnload(); | |
} | |
// 외부에서 입력되는 style이 hook인 경우, onLayout 재실행 | |
render() { | |
return ( | |
<View onLayout={this.onLayout} style={[this.props.style, {justifyContent: 'center', alignItems: 'center'}]}> | |
{this.state.didLayout && <Image width={this.state.viewWidth} height={this.state.viewHeight} resizeMode="contain" style={{position: 'absolute', width: '100%', height: '100%'}} onLoad={this.onImageLoad} source={{uri: this.state.uri}}/>} | |
{this.state.imageLoaded && <Cropper resizedImageWidth={this.state.resizedImageWidth} resizedImageHeight={this.state.resizedImageHeight} imageWidth={this.state.imageWidth} imageHeight={this.state.imageHeight} setPoints={this.setPoints}/>} | |
</View> | |
) | |
} | |
} |
Cropper
좌상단, 우상단, 좌하단, 우하단 순서로 p1, p2, p3, p4입니다. 캔버스 상의 포인트들과 순서가 같습니다.
단, Cropper에서는 absolute bottom을 사용해서 각 포인트의 위치를 조절하기 때문에, 포인트가 움직일 때마다, absolute top 기준으로 변환해 줍니다.
그리고 Perspective Cropper로 부터 화면에 표시되는 이미지의 크기와 실제 이미지의 크기가 둘 다 넘어오기 때문에, 이것을 이용하여 가중치를 구하고, absolute top 기준으로 변환된 값에 곱해서 실제 이미지 상의 좌표를 구해줍니다.
p12는 p1과 p2 사이의 View이고, p12, p13, p23, p34의 left와 bottom 값은 onGestureEvent에서 관련된 포인트의 left와 bottom이 변할 때마다 자동으로 바뀝니다.
pCenter는 가운데 일정 영역을 차지하는 View입니다.
적지 않은 애니메이션인데, react-native-reanimated 덕분에 무리없이 동작합니다.
여기에 더하여 각 포인트마다 움직임을 제한하느라 Cropper 만드는게 제일 힘들었던 것 같습니다.
import React, { useCallback, useEffect, useState } from 'react' | |
import { LayoutChangeEvent, StyleProp, View, ViewStyle } from 'react-native' | |
import { PanGestureHandler, PanGestureHandlerGestureEvent } from 'react-native-gesture-handler' | |
import Animated, { runOnJS, useAnimatedGestureHandler, useAnimatedProps, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' | |
import { Svg, Path } from 'react-native-svg' | |
import { Point } from '.' | |
const AnimatedSvg = Animated.createAnimatedComponent(Svg); | |
const AnimatedPath = Animated.createAnimatedComponent(Path); | |
const pointContainerStyle: StyleProp<Animated.AnimateStyle<StyleProp<ViewStyle>>> = { | |
position: 'absolute', | |
justifyContent: 'center', | |
alignItems: 'center', | |
width: 100, | |
height: 70 | |
} | |
const sidePointContainerStyle: StyleProp<Animated.AnimateStyle<StyleProp<ViewStyle>>> = { | |
position: 'absolute', | |
justifyContent: 'center', | |
alignItems: 'center', | |
width: 50, | |
height: 50 | |
} | |
type PointContext = { | |
startLeft: number; | |
startBottom: number; | |
} | |
type Point12Context = { | |
startP1Bottom: number; | |
startP2Bottom: number; | |
} | |
type Point13Context = { | |
startP1Left: number; | |
startP3Left: number; | |
} | |
type Point24Context = { | |
startP2Left: number; | |
startP4Left: number; | |
} | |
type Point34Context = { | |
startP3Bottom: number; | |
startP4Bottom: number; | |
} | |
type CenterPointContext = { | |
startP1Left: number; | |
startP1Bottom: number; | |
startP2Left: number; | |
startP2Bottom: number; | |
startP3Left: number; | |
startP3Bottom: number; | |
startP4Left: number; | |
startP4Bottom: number; | |
} | |
interface CropperProps { | |
resizedImageWidth: number; | |
resizedImageHeight: number; | |
imageWidth: number; | |
imageHeight: number; | |
setPoints: ((point: Point) => void)[]; | |
style?: ViewStyle; | |
} | |
export default ({resizedImageWidth, resizedImageHeight, imageWidth, imageHeight, setPoints: [setP1, setP2, setP3, setP4]}: CropperProps) => { | |
const realWeight = useSharedValue(imageWidth/resizedImageWidth); | |
const [containerStyle, setContainerStyle] = useState({width:resizedImageWidth, height: resizedImageHeight}); | |
useEffect(() => { | |
setContainerStyle({width: resizedImageWidth, height: resizedImageHeight}); | |
return () => {} | |
}, [resizedImageWidth, resizedImageHeight]); | |
const p1ContainerLeft = useSharedValue(-100); | |
const p1ContainerBottom = useSharedValue(-100); | |
const p2ContainerLeft = useSharedValue(-100); | |
const p2ContainerBottom = useSharedValue(-100); | |
const p3ContainerLeft = useSharedValue(-100); | |
const p3ContainerBottom = useSharedValue(-100); | |
const p4ContainerLeft = useSharedValue(-100); | |
const p4ContainerBottom = useSharedValue(-100); | |
const pathAnimatedProps = useAnimatedProps(() => { | |
const path = `M0 0 L${containerStyle.width} 0 L${containerStyle.width} ${containerStyle.height} L0 ${containerStyle.height} M${p1ContainerLeft.value + 50} ${resizedImageHeight - p1ContainerBottom.value - 35} L${p2ContainerLeft.value + 50} ${resizedImageHeight - p2ContainerBottom.value - 35} L${p4ContainerLeft.value + 50} ${resizedImageHeight - p4ContainerBottom.value - 35} L${p3ContainerLeft.value + 50} ${resizedImageHeight - p3ContainerBottom.value - 35} z`; | |
return { | |
d: path, | |
}; | |
}); | |
const p1ContainerStyle = useAnimatedStyle(() => { | |
return { | |
left: p1ContainerLeft.value, | |
bottom: p1ContainerBottom.value | |
} | |
}); | |
// 오른쪽 x 양수, 아래쪽 y 양수 | |
const p1EventHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent,PointContext>({ | |
onStart: (_, ctx) => { | |
ctx.startLeft = p1ContainerLeft.value; | |
ctx.startBottom = p1ContainerBottom.value; | |
}, | |
onActive: (event, ctx) => { | |
const {translationX, translationY} = event; | |
const newP1ContainerLeft = ctx.startLeft + translationX; | |
const newP1ContainerBottom = ctx.startBottom - translationY; | |
const w = (p2ContainerBottom.value - p3ContainerBottom.value) / (p2ContainerLeft.value - p3ContainerLeft.value); | |
const leftLimit1 = p3ContainerLeft.value + ((newP1ContainerBottom - p3ContainerBottom.value) / w); | |
const leftLimit = Math.min(leftLimit1, p2ContainerLeft.value) - 25; | |
const bottomLimit1 = p3ContainerBottom.value + ((newP1ContainerLeft - p2ContainerLeft.value) * w); | |
const bottomLimit = Math.max(bottomLimit1, p3ContainerBottom.value) + 25; | |
if(newP1ContainerLeft > leftLimit || newP1ContainerBottom < bottomLimit) return; | |
p1ContainerLeft.value = newP1ContainerLeft < -50 ? -50 : (newP1ContainerLeft > resizedImageWidth - 50 ? resizedImageWidth - 50 : newP1ContainerLeft); | |
p1ContainerBottom.value = newP1ContainerBottom < -35 ? -35 : (newP1ContainerBottom > resizedImageHeight - 35 ? resizedImageHeight - 35 : newP1ContainerBottom); | |
runOnJS(setP1)({x: (p1ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p1ContainerBottom.value - 35) * realWeight.value}); | |
}, | |
}); | |
const p2ContainerStyle = useAnimatedStyle(() => { | |
return { | |
left: p2ContainerLeft.value, | |
bottom: p2ContainerBottom.value | |
} | |
}) | |
// 오른쪽 x 양수, 아래쪽 y 양수 | |
const p2EventHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent,PointContext>({ | |
onStart: (_, ctx) => { | |
ctx.startLeft = p2ContainerLeft.value; | |
ctx.startBottom = p2ContainerBottom.value; | |
}, | |
onActive: (event, ctx) => { | |
const {translationX, translationY} = event; | |
const newP2ContainerLeft = ctx.startLeft + translationX; | |
const newP2ContainerBottom = ctx.startBottom - translationY; | |
const w = (p1ContainerBottom.value - p4ContainerBottom.value) / (p4ContainerLeft.value - p1ContainerLeft.value); | |
const leftLimit1 = p1ContainerLeft.value + ((newP2ContainerBottom - p1ContainerBottom.value) / w); | |
const leftLimit = Math.max(leftLimit1, p1ContainerLeft.value) - 25; | |
const bottomLimit1 = p1ContainerBottom.value + ((p1ContainerLeft.value - newP2ContainerLeft) * w); | |
const bottomLimit = Math.max(bottomLimit1, p4ContainerBottom.value) + 25; | |
if(newP2ContainerLeft < leftLimit || newP2ContainerBottom < bottomLimit) return; | |
p2ContainerLeft.value = newP2ContainerLeft < -50 ? -50 : (newP2ContainerLeft > resizedImageWidth - 50 ? resizedImageWidth - 50 : newP2ContainerLeft); | |
p2ContainerBottom.value = newP2ContainerBottom < -35 ? -35 : (newP2ContainerBottom > resizedImageHeight - 35 ? resizedImageHeight - 35 : newP2ContainerBottom); | |
runOnJS(setP2)({x: (p2ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p2ContainerBottom.value - 35) * realWeight.value}); | |
}, | |
}); | |
const p3ContainerStyle = useAnimatedStyle(() => { | |
return { | |
left: p3ContainerLeft.value, | |
bottom: p3ContainerBottom.value | |
} | |
}) | |
// 오른쪽 x 양수, 아래쪽 y 양수 | |
const p3EventHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent,PointContext>({ | |
onStart: (_, ctx) => { | |
ctx.startLeft = p3ContainerLeft.value; | |
ctx.startBottom = p3ContainerBottom.value; | |
}, | |
onActive: (event, ctx) => { | |
const {translationX, translationY} = event; | |
const newP3ContainerLeft = ctx.startLeft + translationX; | |
const newP3ContainerBottom = ctx.startBottom - translationY; | |
const w = (p1ContainerBottom.value - p4ContainerBottom.value) / (p4ContainerLeft.value - p1ContainerLeft.value); | |
const leftLimit1 = p1ContainerLeft.value + ((p1ContainerBottom.value - newP3ContainerBottom) / w); | |
const leftLimit = Math.max(leftLimit1, p1ContainerLeft.value) - 25; | |
const bottomLimit1 = p4ContainerBottom.value + ((p4ContainerLeft.value - newP3ContainerLeft) * w); | |
const bottomLimit = Math.max(bottomLimit1, p4ContainerBottom.value) + 25; | |
if(newP3ContainerLeft > leftLimit || newP3ContainerBottom > bottomLimit) return; | |
p3ContainerLeft.value = newP3ContainerLeft < -50 ? -50 : (newP3ContainerLeft > resizedImageWidth - 50 ? resizedImageWidth - 50 : (newP3ContainerLeft)); | |
p3ContainerBottom.value = newP3ContainerBottom < -35 ? -35 : (newP3ContainerBottom > resizedImageHeight - 35 ? resizedImageHeight - 35 : (newP3ContainerBottom)); | |
runOnJS(setP3)({x: (p3ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p3ContainerBottom.value - 35) * realWeight.value}); | |
}, | |
}); | |
const p4ContainerStyle = useAnimatedStyle(() => { | |
return { | |
left: p4ContainerLeft.value, | |
bottom: p4ContainerBottom.value | |
} | |
}) | |
// 오른쪽 x 양수, 아래쪽 y 양수 | |
const p4EventHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent,PointContext>({ | |
onStart: (_, ctx) => { | |
ctx.startLeft = p4ContainerLeft.value; | |
ctx.startBottom = p4ContainerBottom.value; | |
}, | |
onActive: (event, ctx) => { | |
const {translationX, translationY} = event; | |
const newP4ContainerLeft = ctx.startLeft + translationX; | |
const newP4ContainerBottom = ctx.startBottom - translationY; | |
const w = (p2ContainerBottom.value - p3ContainerBottom.value) / (p2ContainerLeft.value - p3ContainerLeft.value); | |
const leftLimit1 = p2ContainerLeft.value - ((p2ContainerBottom.value - newP4ContainerBottom) / w); | |
const leftLimit = Math.max(leftLimit1, p3ContainerLeft.value) + 25; | |
const bottomLimit1 = p3ContainerBottom.value + ((newP4ContainerLeft - p3ContainerLeft.value) * w); | |
const bottomLimit = Math.max(bottomLimit1, p2ContainerBottom.value) + 25; | |
if(newP4ContainerLeft < leftLimit || newP4ContainerBottom > bottomLimit) return; | |
p4ContainerLeft.value = newP4ContainerLeft < -50 ? -50 : (newP4ContainerLeft > resizedImageWidth - 50 ? resizedImageWidth - 50 : (newP4ContainerLeft)); | |
p4ContainerBottom.value = newP4ContainerBottom < -35 ? -35 : (newP4ContainerBottom > resizedImageHeight - 35 ? resizedImageHeight - 35 : (newP4ContainerBottom)); | |
runOnJS(setP4)({x: (p4ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p4ContainerBottom.value - 35) * realWeight.value}); | |
}, | |
}); | |
const p12ContainerStyle = useAnimatedStyle(() => { | |
return { | |
left: p1ContainerLeft.value + ((p2ContainerLeft.value - p1ContainerLeft.value) / 2) + 25, | |
bottom: p1ContainerBottom.value + ((p2ContainerBottom.value - p1ContainerBottom.value) / 2) + 10, | |
} | |
}); | |
const p12EventHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent,Point12Context>({ | |
onStart: (_, ctx) => { | |
ctx.startP1Bottom = p1ContainerBottom.value; | |
ctx.startP2Bottom = p2ContainerBottom.value; | |
}, | |
onActive: (event, ctx) => { | |
const {translationY} = event; | |
const newP1ContainerBottom = ctx.startP1Bottom - translationY; | |
const newP2ContainerBottom = ctx.startP2Bottom - translationY; | |
const limit = Math.max(p3ContainerBottom.value, p4ContainerBottom.value) + 35; | |
if(newP1ContainerBottom < limit || newP2ContainerBottom < limit || newP1ContainerBottom > resizedImageHeight - 35 || newP2ContainerBottom > resizedImageHeight - 35) return; | |
p1ContainerBottom.value = newP1ContainerBottom; | |
p2ContainerBottom.value = newP2ContainerBottom; | |
runOnJS(setP1)({x: (p1ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p1ContainerBottom.value - 35) * realWeight.value}); | |
runOnJS(setP2)({x: (p2ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p2ContainerBottom.value - 35) * realWeight.value}); | |
}, | |
}); | |
const p13ContainerStyle = useAnimatedStyle(() => { | |
return { | |
left: p1ContainerLeft.value + ((p3ContainerLeft.value - p1ContainerLeft.value) / 2) + 25, | |
bottom: p1ContainerBottom.value + ((p3ContainerBottom.value - p1ContainerBottom.value) / 2) + 10, | |
} | |
}); | |
const p13EventHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent,Point13Context>({ | |
onStart: (_, ctx) => { | |
ctx.startP1Left = p1ContainerLeft.value; | |
ctx.startP3Left = p3ContainerLeft.value; | |
}, | |
onActive: (event, ctx) => { | |
const {translationX} = event; | |
const newP1ContainerLeft = ctx.startP1Left + translationX; | |
const newP3ContainerLeft = ctx.startP3Left + translationX; | |
const limit = Math.min(p2ContainerLeft.value, p4ContainerLeft.value) - 25; | |
if(newP1ContainerLeft > limit || newP3ContainerLeft > limit || newP1ContainerLeft < -50 || newP3ContainerLeft < -50) return; | |
p1ContainerLeft.value = newP1ContainerLeft; | |
p3ContainerLeft.value = newP3ContainerLeft; | |
runOnJS(setP1)({x: (p1ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p1ContainerBottom.value - 35) * realWeight.value}); | |
runOnJS(setP3)({x: (p3ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p3ContainerBottom.value - 35) * realWeight.value}); | |
}, | |
}); | |
const p24ContainerStyle = useAnimatedStyle(() => { | |
return { | |
left: p2ContainerLeft.value + ((p4ContainerLeft.value - p2ContainerLeft.value) / 2) + 25, | |
bottom: p2ContainerBottom.value + ((p4ContainerBottom.value - p2ContainerBottom.value) / 2) + 10, | |
} | |
}); | |
const p24EventHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent,Point24Context>({ | |
onStart: (_, ctx) => { | |
ctx.startP2Left = p2ContainerLeft.value; | |
ctx.startP4Left = p4ContainerLeft.value; | |
}, | |
onActive: (event, ctx) => { | |
const {translationX} = event; | |
const newP2ContainerLeft = ctx.startP2Left + translationX; | |
const newP4ContainerLeft = ctx.startP4Left + translationX; | |
const limit = Math.max(p1ContainerLeft.value, p3ContainerLeft.value) + 25; | |
if(newP2ContainerLeft < limit || newP4ContainerLeft < limit || newP2ContainerLeft > resizedImageWidth - 50 || newP4ContainerLeft > resizedImageWidth - 50) return; | |
p2ContainerLeft.value = newP2ContainerLeft; | |
p4ContainerLeft.value = newP4ContainerLeft; | |
runOnJS(setP2)({x: (p2ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p2ContainerBottom.value - 35) * realWeight.value}); | |
runOnJS(setP4)({x: (p4ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p4ContainerBottom.value - 35) * realWeight.value}); | |
}, | |
}); | |
const p34ContainerStyle = useAnimatedStyle(() => { | |
return { | |
left: p3ContainerLeft.value + ((p4ContainerLeft.value - p3ContainerLeft.value) / 2) + 25, | |
bottom: p3ContainerBottom.value + ((p4ContainerBottom.value - p3ContainerBottom.value) / 2) + 10, | |
} | |
}); | |
const p34EventHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent,Point34Context>({ | |
onStart: (_, ctx) => { | |
ctx.startP3Bottom = p3ContainerBottom.value; | |
ctx.startP4Bottom = p4ContainerBottom.value; | |
}, | |
onActive: (event, ctx) => { | |
const {translationY} = event; | |
const newP3ContainerBottom = ctx.startP3Bottom - translationY; | |
const newP4ContainerBottom = ctx.startP4Bottom - translationY; | |
const limit = Math.min(p1ContainerBottom.value, p2ContainerBottom.value) - 25; | |
if(newP3ContainerBottom > limit || newP4ContainerBottom > limit || newP3ContainerBottom < -35 || newP4ContainerBottom < -35) return; | |
p3ContainerBottom.value = newP3ContainerBottom; | |
p4ContainerBottom.value = newP4ContainerBottom; | |
runOnJS(setP3)({x: (p3ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p3ContainerBottom.value - 35) * realWeight.value}); | |
runOnJS(setP4)({x: (p4ContainerLeft.value + 50) * realWeight.value, y: (resizedImageHeight - p4ContainerBottom.value - 35) * realWeight.value}); | |
}, | |
}); | |
const pCenterContainerStyle = useAnimatedStyle(() => { | |
const width = Math.min(p4ContainerLeft.value - p3ContainerLeft.value, p2ContainerLeft.value - p1ContainerLeft.value) * 0.5; | |
const height = Math.min(p1ContainerBottom.value - p3ContainerBottom.value, p2ContainerBottom.value - p4ContainerBottom.value) * 0.5; | |
const cp1Left = (p1ContainerLeft.value + 50) + ((p4ContainerLeft.value - p1ContainerLeft.value) / 2) - (width / 2); | |
const cp1Bottom = (p1ContainerBottom.value + 35) + ((p4ContainerBottom.value - p1ContainerBottom.value) / 2) - (height / 2); | |
const cp2Left = (p3ContainerLeft.value + 50) + ((p2ContainerLeft.value - p3ContainerLeft.value) / 2) - (width / 2); | |
const cp2Bottom = (p3ContainerBottom.value + 35) + ((p2ContainerBottom.value - p3ContainerBottom.value) / 2) - (height / 2); | |
return { | |
width, | |
height, | |
left: cp1Left < cp2Left ? cp1Left + (cp2Left - cp1Left)/2 : cp2Left + (cp1Left - cp2Left)/2, | |
bottom: cp1Bottom < cp2Bottom ? cp1Bottom + (cp2Bottom - cp1Bottom)/2 : cp2Bottom + (cp1Bottom - cp2Bottom)/2 | |
} | |
}) | |
// 오른쪽 x 양수, 아래쪽 y 양수 | |
const pCenterEventHandler = useAnimatedGestureHandler<PanGestureHandlerGestureEvent, CenterPointContext>({ | |
onStart: (_, ctx) => { | |
ctx.startP1Left = p1ContainerLeft.value; | |
ctx.startP1Bottom = p1ContainerBottom.value; | |
ctx.startP2Left = p2ContainerLeft.value; | |
ctx.startP2Bottom = p2ContainerBottom.value; | |
ctx.startP3Left = p3ContainerLeft.value; | |
ctx.startP3Bottom = p3ContainerBottom.value; | |
ctx.startP4Left = p4ContainerLeft.value; | |
ctx.startP4Bottom = p4ContainerBottom.value; | |
}, | |
onActive: (event, ctx) => { | |
const {translationX, translationY} = event; | |
const newP1ContainerLeft = ctx.startP1Left + translationX; | |
const newP1ContainerBottom = ctx.startP1Bottom - translationY; | |
const newP2ContainerLeft = ctx.startP2Left + translationX; | |
const newP2ContainerBottom = ctx.startP2Bottom - translationY; | |
const newP3ContainerLeft = ctx.startP3Left + translationX; | |
const newP3ContainerBottom = ctx.startP3Bottom - translationY; | |
const newP4ContainerLeft = ctx.startP4Left + translationX; | |
const newP4ContainerBottom = ctx.startP4Bottom - translationY; | |
let newP1ContainerLeftValue = p1ContainerLeft.value; | |
let newP1ContainerBottomValue = p1ContainerBottom.value; | |
let p1ContainerPointChanged = false; | |
if(newP2ContainerLeft < resizedImageWidth - 50 && newP3ContainerLeft > -50 && newP4ContainerLeft < resizedImageWidth - 50) { | |
newP1ContainerLeftValue = newP1ContainerLeft < -50 ? -50 : (newP1ContainerLeft > resizedImageWidth - 50 ? resizedImageWidth - 50 : (newP1ContainerLeft)); | |
p1ContainerLeft.value = newP1ContainerLeftValue; | |
p1ContainerPointChanged = true; | |
} | |
if(newP2ContainerBottom < resizedImageHeight - 35 && newP3ContainerBottom > -35 && newP4ContainerBottom > -35) { | |
newP1ContainerBottomValue = newP1ContainerBottom < -35 ? -35 : (newP1ContainerBottom > resizedImageHeight - 35 ? resizedImageHeight - 35 : (newP1ContainerBottom)); | |
p1ContainerBottom.value = newP1ContainerBottomValue; | |
p1ContainerPointChanged = true; | |
} | |
if(p1ContainerPointChanged) { | |
runOnJS(setP1)({x: (newP1ContainerLeftValue + 50) * realWeight.value, y: (resizedImageHeight - newP1ContainerBottomValue - 35) * realWeight.value}); | |
} | |
let newP2ContainerLeftValue = p2ContainerLeft.value; | |
let newP2ContainerBottomValue = p2ContainerBottom.value; | |
let p2ContainerPointChanged = false; | |
if(newP1ContainerLeft > -50 && newP3ContainerLeft > -50 && newP4ContainerLeft < resizedImageWidth - 50) { | |
newP2ContainerLeftValue = newP2ContainerLeft < -50 ? -50 : (newP2ContainerLeft > resizedImageWidth - 50 ? resizedImageWidth - 50 : (newP2ContainerLeft)); | |
p2ContainerLeft.value = newP2ContainerLeftValue; | |
p2ContainerPointChanged = true; | |
} | |
if(newP1ContainerBottom < resizedImageHeight -35 && newP3ContainerBottom > -35 && newP4ContainerBottom > -35) { | |
newP2ContainerBottomValue = newP2ContainerBottom < -35 ? -35 : (newP2ContainerBottom > resizedImageHeight - 35 ? resizedImageHeight - 35 : (newP2ContainerBottom)); | |
p2ContainerBottom.value = newP2ContainerBottomValue; | |
p2ContainerPointChanged = true; | |
} | |
if(p2ContainerPointChanged) { | |
runOnJS(setP2)({x: (newP2ContainerLeftValue + 50) * realWeight.value, y: (resizedImageHeight - newP2ContainerBottomValue - 35) * realWeight.value}); | |
} | |
let newP3ContainerLeftValue = p3ContainerLeft.value; | |
let newP3ContainerBottomValue = p3ContainerBottom.value; | |
let p3ContainerPointChanged = false; | |
if(newP1ContainerLeft > -50 && newP2ContainerLeft < resizedImageWidth - 50 && newP4ContainerLeft < resizedImageWidth - 50) { | |
newP3ContainerLeftValue = newP3ContainerLeft < -50 ? -50 : (newP3ContainerLeft > resizedImageWidth - 50 ? resizedImageWidth - 50 : (newP3ContainerLeft)); | |
p3ContainerLeft.value = newP3ContainerLeftValue; | |
p3ContainerPointChanged = true; | |
} | |
if(newP1ContainerBottom < resizedImageHeight - 35 && newP2ContainerBottom < resizedImageHeight - 35 && newP4ContainerBottom > -35) { | |
newP3ContainerBottomValue = newP3ContainerBottom < -35 ? -35 : (newP3ContainerBottom > resizedImageHeight - 35 ? resizedImageHeight - 35 : (newP3ContainerBottom)); | |
p3ContainerBottom.value = newP3ContainerBottomValue; | |
p3ContainerPointChanged = true; | |
} | |
if(p3ContainerPointChanged) { | |
runOnJS(setP3)({x: (newP3ContainerLeftValue + 50) * realWeight.value, y: (resizedImageHeight - newP3ContainerBottomValue - 35) * realWeight.value}); | |
} | |
let newP4ContainerLeftValue = p4ContainerLeft.value; | |
let newP4ContainerBottomValue = p4ContainerBottom.value; | |
let p4ContainerPointChanged = false; | |
if(newP1ContainerLeft > -50 && newP2ContainerLeft < resizedImageWidth - 50 && newP3ContainerLeft > -50) { | |
newP4ContainerLeftValue = newP4ContainerLeft < -50 ? -50 : (newP4ContainerLeft > resizedImageWidth - 50 ? resizedImageWidth - 50 : (newP4ContainerLeft)); | |
p4ContainerLeft.value = newP4ContainerLeftValue; | |
p4ContainerPointChanged = true; | |
} | |
if(newP1ContainerBottom < resizedImageHeight - 35 && newP2ContainerBottom < resizedImageHeight - 35 && newP3ContainerBottom > -35) { | |
newP4ContainerBottomValue = newP4ContainerBottom < -35 ? -35 : (newP4ContainerBottom > resizedImageHeight - 35 ? resizedImageHeight - 35 : (newP4ContainerBottom)); | |
p4ContainerBottom.value = newP4ContainerBottomValue; | |
p4ContainerPointChanged = true; | |
} | |
if(p4ContainerPointChanged) { | |
runOnJS(setP4)({x: (newP4ContainerLeftValue + 50) * realWeight.value, y: (resizedImageHeight - newP4ContainerBottomValue - 35) * realWeight.value}); | |
} | |
}, | |
}); | |
useEffect(() => { | |
const newRealWeight = imageWidth / resizedImageWidth; | |
realWeight.value = newRealWeight | |
setP1({x: (resizedImageWidth * 0.2) * newRealWeight, y: (resizedImageHeight * 0.2) * newRealWeight}); | |
setP2({x: (resizedImageWidth * 0.8) * newRealWeight, y: (resizedImageHeight * 0.2) * newRealWeight}); | |
setP3({x: (resizedImageWidth * 0.2) * newRealWeight, y: (resizedImageHeight * 0.8) * newRealWeight}); | |
setP4({x: (resizedImageWidth * 0.8) * newRealWeight, y: (resizedImageHeight * 0.8) * newRealWeight}); | |
return () => {} | |
}, [imageWidth, resizedImageWidth, resizedImageHeight]); | |
// 이미지가 바뀐다고 실행되지 않는다. 이미지의 비율이 바뀌어야 실행된다. | |
const onLayout = useCallback((e: LayoutChangeEvent) => { | |
const {width, height} = e.nativeEvent.layout; | |
p1ContainerLeft.value = width * 0.2 - 50; | |
p1ContainerBottom.value = height * 0.8 - 35; | |
p2ContainerLeft.value = width * 0.8 - 50; | |
p2ContainerBottom.value = height * 0.8 - 35; | |
p3ContainerLeft.value = width * 0.2 - 50; | |
p3ContainerBottom.value = height * 0.2 - 35; | |
p4ContainerLeft.value = width * 0.8 - 50; | |
p4ContainerBottom.value = height * 0.2 - 35; | |
}, []); | |
return ( | |
<View onLayout={onLayout} style={[containerStyle, {position: 'absolute', overflow: 'hidden'}]}> | |
<View style={{position: 'absolute', width: containerStyle.width + 4, height: containerStyle.height + 4, left: -2, top: -2}}> | |
<AnimatedSvg width="100%" height="100%" viewBox={`0 0 ${containerStyle.width} ${containerStyle.height}`}> | |
<AnimatedPath animatedProps={pathAnimatedProps} fill="rgba(0, 0, 0, 0.5)" fillRule="evenodd" stroke="rgba(255, 255, 255, 0.75)" strokeWidth="2"/> | |
</AnimatedSvg> | |
</View> | |
<PanGestureHandler onGestureEvent={p1EventHandler}> | |
<Animated.View style={[pointContainerStyle, p1ContainerStyle]}/> | |
</PanGestureHandler> | |
<PanGestureHandler onGestureEvent={p2EventHandler}> | |
<Animated.View style={[pointContainerStyle, p2ContainerStyle]}/> | |
</PanGestureHandler> | |
<PanGestureHandler onGestureEvent={p3EventHandler}> | |
<Animated.View style={[pointContainerStyle, p3ContainerStyle]}/> | |
</PanGestureHandler> | |
<PanGestureHandler onGestureEvent={p4EventHandler}> | |
<Animated.View style={[pointContainerStyle, p4ContainerStyle]}/> | |
</PanGestureHandler> | |
<PanGestureHandler onGestureEvent={p12EventHandler}> | |
<Animated.View style={[sidePointContainerStyle, p12ContainerStyle]}/> | |
</PanGestureHandler> | |
<PanGestureHandler onGestureEvent={p13EventHandler}> | |
<Animated.View style={[sidePointContainerStyle, p13ContainerStyle]}/> | |
</PanGestureHandler> | |
<PanGestureHandler onGestureEvent={p24EventHandler}> | |
<Animated.View style={[sidePointContainerStyle, p24ContainerStyle]}/> | |
</PanGestureHandler> | |
<PanGestureHandler onGestureEvent={p34EventHandler}> | |
<Animated.View style={[sidePointContainerStyle, p34ContainerStyle]}/> | |
</PanGestureHandler> | |
<PanGestureHandler onGestureEvent={pCenterEventHandler}> | |
<Animated.View style={[pointContainerStyle, pCenterContainerStyle]}/> | |
</PanGestureHandler> | |
</View> | |
) | |
} |
'리액트 네이티브' 카테고리의 다른 글
리액트 네이티브에서 JS와 Java가 통신하는 방법 (1) | 2024.08.27 |
---|---|
RN Perspective Crop - 크롭 사이즈편 (1) | 2021.05.18 |
RN Perspective Crop - OpenCV편 (0) | 2021.05.17 |
리액트 네이티브 튜토리얼 기능 구현3 - Example (0) | 2021.03.21 |
리액트 네이티브 튜토리얼 기능 구현2 - Components (0) | 2021.03.21 |