1import { ImageContentFit, ImageContentPosition, Image } from 'expo-image';
2import React from 'react';
3import {
4  Dimensions,
5  Image as RNImage,
6  ImageResizeMode,
7  ScrollView,
8  StyleSheet,
9  Text,
10  TextInput,
11  View,
12} from 'react-native';
13import { PanGestureHandler, PanGestureHandlerGestureEvent } from 'react-native-gesture-handler';
14import Animated, {
15  useAnimatedGestureHandler,
16  useAnimatedProps,
17  useAnimatedStyle,
18  useDerivedValue,
19  useSharedValue,
20} from 'react-native-reanimated';
21
22import { FunctionParameter, useArguments } from '../../components/FunctionDemo';
23import Configurator from '../../components/FunctionDemo/Configurator';
24import { Colors } from '../../constants';
25
26type CustomViewProps = React.PropsWithChildren<object>;
27
28type ContextType = {
29  x: number;
30  y: number;
31};
32
33const PADDING = 20;
34const HANDLE_SIZE = 25;
35const HANDLE_SLOP = 10;
36const WINDOW_DIMENSIONS = Dimensions.get('window');
37const MAX_WIDTH = WINDOW_DIMENSIONS.width - 2 * PADDING;
38const MAX_HEIGHT = WINDOW_DIMENSIONS.height - 330;
39
40const ResizableView: React.FC<CustomViewProps> = ({ children }) => {
41  const width = useSharedValue(300);
42  const height = useSharedValue(300);
43
44  const panGestureEvent = useAnimatedGestureHandler<PanGestureHandlerGestureEvent, ContextType>({
45    onStart: (_, context) => {
46      context.x = width.value;
47      context.y = height.value;
48    },
49    onActive: (event, context) => {
50      width.value = Math.max(HANDLE_SIZE, Math.min(event.translationX + context.x, MAX_WIDTH));
51      height.value = Math.max(HANDLE_SIZE, Math.min(event.translationY + context.y, MAX_HEIGHT));
52    },
53  });
54
55  const canvasStyle = useAnimatedStyle(() => {
56    return {
57      width: width.value,
58      height: height.value,
59    };
60  }, [width, height]);
61
62  const text = useDerivedValue(() => `${Math.round(width.value)}x${Math.round(height.value)}`);
63  const animatedProps = useAnimatedProps(() => {
64    return {
65      text: text.value,
66      // Here we use any because the text prop is not available in the type
67    } as any;
68  });
69  const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
70
71  return (
72    <View>
73      <AnimatedTextInput
74        editable={false}
75        value={text.value}
76        underlineColorAndroid="transparent"
77        style={styles.sizeText}
78        {...{ animatedProps }}
79      />
80      <View style={styles.resizableView}>
81        <Text style={styles.hintText}>
82          Move the handle above to resize the image canvas and see how it lays out in different
83          components, sizes and resize modes
84        </Text>
85        <Animated.View style={[styles.canvas, canvasStyle]}>
86          {children}
87
88          <PanGestureHandler onGestureEvent={panGestureEvent}>
89            <Animated.View style={styles.resizeHandle}>
90              <View style={styles.resizeHandleChild} />
91            </Animated.View>
92          </PanGestureHandler>
93        </Animated.View>
94      </View>
95    </View>
96  );
97};
98
99const parameters: FunctionParameter[] = [
100  {
101    name: 'Use React Native Image',
102    type: 'boolean',
103    initial: false,
104  },
105  {
106    name: 'Size',
107    type: 'enum',
108    values: [
109      { name: '1500x1000', value: '1500/1000' },
110      { name: '1000x1500', value: '1000/1500' },
111      { name: '300x300', value: '300/300' },
112      { name: '100x100', value: '100/100' },
113    ],
114  },
115  {
116    name: 'Content fit',
117    type: 'enum',
118    values: [
119      { name: 'cover', value: ImageContentFit.COVER },
120      { name: 'contain', value: ImageContentFit.CONTAIN },
121      { name: 'fill', value: ImageContentFit.FILL },
122      { name: 'none', value: ImageContentFit.NONE },
123      { name: 'scale-down', value: ImageContentFit.SCALE_DOWN },
124    ],
125  },
126  {
127    name: 'Content position',
128    type: 'enum',
129    values: [
130      { name: 'top 50%, left 50%', value: { top: '50%', left: '50%' } },
131      { name: 'top 0, right 0', value: { top: 0, right: 0 } },
132      { name: 'top 100, left 50', value: { top: 100, left: 50 } },
133      { name: 'bottom 10%, right 25%', value: { bottom: '10%', right: '25%' } },
134      { name: 'bottom 0, right 10', value: { bottom: 0, right: 10 } },
135    ],
136  },
137  {
138    name: 'Use responsive sources',
139    type: 'boolean',
140    initial: false,
141  },
142];
143
144function mapContentFitToResizeMode(contentFit: ImageContentFit): ImageResizeMode {
145  switch (contentFit) {
146    case ImageContentFit.COVER:
147    case ImageContentFit.CONTAIN:
148      return contentFit;
149    case ImageContentFit.FILL:
150      return 'stretch';
151    case ImageContentFit.NONE:
152    case ImageContentFit.SCALE_DOWN:
153      return 'center';
154  }
155}
156
157export default function ImageResizableScreen() {
158  const [seed] = React.useState(1 + Math.round(Math.random() * 10));
159  const [args, updateArgument] = useArguments(parameters);
160  const [showReactNativeComponent, size, contentFit, contentPosition, useResponsiveSources] =
161    args as [boolean, string, ImageContentFit, ImageContentPosition, boolean];
162  const ImageComponent: React.ElementType = showReactNativeComponent ? RNImage : Image;
163  const source = useResponsiveSources
164    ? [
165        { uri: `https://picsum.photos/id/238/800/800`, width: 800, height: 800 },
166        { uri: `https://picsum.photos/id/237/500/500`, width: 500, height: 500 },
167        { uri: `https://picsum.photos/id/236/300/300`, width: 300, height: 300 },
168      ]
169    : { uri: `https://picsum.photos/seed/${seed}/${size}` };
170
171  return (
172    <ScrollView style={styles.container}>
173      <ResizableView>
174        <ImageComponent
175          style={styles.image}
176          source={source}
177          contentFit={contentFit}
178          contentPosition={contentPosition}
179          resizeMode={mapContentFitToResizeMode(contentFit)}
180        />
181      </ResizableView>
182
183      <View style={styles.configurator}>
184        <Configurator parameters={parameters} onChange={updateArgument} value={args} />
185      </View>
186    </ScrollView>
187  );
188}
189
190const styles = StyleSheet.create({
191  container: {
192    flex: 1,
193  },
194  resizableView: {
195    margin: PADDING,
196    width: MAX_WIDTH,
197    height: MAX_HEIGHT,
198    borderWidth: 1,
199    borderColor: Colors.border,
200    backgroundColor: '#eef',
201  },
202  configurator: {
203    flex: 1,
204    paddingHorizontal: 15,
205  },
206  canvas: {
207    margin: -1,
208    minWidth: HANDLE_SIZE,
209    minHeight: HANDLE_SIZE,
210    maxWidth: MAX_WIDTH,
211    maxHeight: MAX_HEIGHT,
212    backgroundColor: '#00f2',
213    borderWidth: 2,
214    borderStyle: 'dotted',
215    borderColor: Colors.tintColor,
216    borderRadius: 3,
217  },
218  resizeHandle: {
219    padding: HANDLE_SLOP,
220    position: 'absolute',
221    bottom: -HANDLE_SIZE / 2 - HANDLE_SLOP,
222    right: -HANDLE_SIZE / 2 - HANDLE_SLOP,
223  },
224  resizeHandleChild: {
225    width: HANDLE_SIZE,
226    height: HANDLE_SIZE,
227    borderRadius: HANDLE_SIZE,
228    borderWidth: 3,
229    borderColor: Colors.tintColor,
230    backgroundColor: '#fff',
231  },
232  hintText: {
233    color: Colors.secondaryText,
234    textAlign: 'center',
235    position: 'absolute',
236    right: 10,
237    left: 10,
238    bottom: 16,
239  },
240  sizeText: {
241    position: 'absolute',
242    zIndex: 1,
243    top: -PADDING + 8,
244    right: PADDING - 4,
245    color: Colors.secondaryText,
246  },
247  image: {
248    flex: 1,
249  },
250});
251