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