메인 콘텐츠로 건너뛰기

React Native 지원

Dropup은 네이티브 어댑터를 통해 React Native에서 작동합니다.

설치

npm install @samithahansaka/dropup

# 이미지 선택용
npx expo install expo-image-picker

# 문서 선택용
npx expo install expo-document-picker

# 이미지 조작용 (선택 사항)
npx expo install expo-image-manipulator

기본 설정

import { useDropup } from '@samithahansaka/dropup';
import { NativeAdapter } from '@samithahansaka/dropup/native';
import * as ImagePicker from 'expo-image-picker';
import { View, Text, Button, Image, FlatList } from 'react-native';

function NativeUploader() {
const { files, actions, state } = useDropup({
// 네이티브 어댑터 사용
adapter: NativeAdapter,

upload: {
url: 'https://your-api.com/upload',
},
});

const pickImage = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsMultipleSelection: true,
quality: 0.8,
});

if (!result.canceled) {
// File 유사 객체로 변환
const files = result.assets.map(asset => ({
uri: asset.uri,
name: asset.fileName || 'photo.jpg',
type: asset.mimeType || 'image/jpeg',
}));

actions.addFiles(files);
}
};

return (
<View style={styles.container}>
<Button title="Pick Images" onPress={pickImage} />

<FlatList
data={files}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<View style={styles.fileItem}>
{item.preview && (
<Image source={{ uri: item.preview }} style={styles.preview} />
)}
<Text>{item.name}</Text>
<Text>{item.status}</Text>
{item.status === 'uploading' && (
<Text>{item.progress}%</Text>
)}
</View>
)}
/>

<Button
title="Upload All"
onPress={() => actions.upload()}
disabled={state.isUploading}
/>
</View>
);
}

const styles = {
container: {
flex: 1,
padding: 20,
},
fileItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 10,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
preview: {
width: 50,
height: 50,
marginRight: 10,
borderRadius: 4,
},
};

이미지 피커 통합

갤러리에서 선택

import * as ImagePicker from 'expo-image-picker';

const pickFromGallery = async () => {
// 권한 요청
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
alert('Permission required');
return;
}

const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.All,
allowsMultipleSelection: true,
quality: 0.8,
selectionLimit: 10,
});

if (!result.canceled) {
const files = result.assets.map(asset => ({
uri: asset.uri,
name: asset.fileName || `photo_${Date.now()}.jpg`,
type: asset.mimeType || 'image/jpeg',
size: asset.fileSize,
}));

actions.addFiles(files);
}
};

사진 촬영

const takePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
alert('Camera permission required');
return;
}

const result = await ImagePicker.launchCameraAsync({
quality: 0.8,
allowsEditing: true,
aspect: [4, 3],
});

if (!result.canceled) {
const asset = result.assets[0];
actions.addFiles([{
uri: asset.uri,
name: `photo_${Date.now()}.jpg`,
type: asset.mimeType || 'image/jpeg',
}]);
}
};

문서 피커

import * as DocumentPicker from 'expo-document-picker';

const pickDocument = async () => {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/pdf', 'application/msword'],
multiple: true,
});

if (result.type === 'success') {
actions.addFiles([{
uri: result.uri,
name: result.name,
type: result.mimeType,
size: result.size,
}]);
}
};

이미지 압축 (네이티브)

import * as ImageManipulator from 'expo-image-manipulator';

async function compressNativeImage(uri: string): Promise<string> {
const result = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 1200 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG }
);

return result.uri;
}

// Dropup과 함께 사용
const { actions } = useDropup({
onFilesAdded: async (newFiles) => {
for (const file of newFiles) {
if (file.type.startsWith('image/')) {
const compressedUri = await compressNativeImage(file.uri);
actions.updateFileMeta(file.id, { compressedUri });
}
}
},
});

전체 네이티브 예제

import React from 'react';
import {
View,
Text,
TouchableOpacity,
Image,
FlatList,
StyleSheet,
ActivityIndicator,
} from 'react-native';
import { useDropup } from '@samithahansaka/dropup';
import { NativeAdapter } from '@samithahansaka/dropup/native';
import * as ImagePicker from 'expo-image-picker';
import * as DocumentPicker from 'expo-document-picker';

export default function FileUploader() {
const { files, actions, state } = useDropup({
adapter: NativeAdapter,
maxFiles: 10,
upload: {
url: 'https://your-api.com/upload',
headers: {
'Authorization': 'Bearer token',
},
},
onUploadComplete: (file) => {
console.log('Uploaded:', file.uploadedUrl);
},
onUploadError: (file, error) => {
console.error('Failed:', error.message);
},
});

const pickImages = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') return;

const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsMultipleSelection: true,
});

if (!result.canceled) {
const files = result.assets.map(asset => ({
uri: asset.uri,
name: asset.fileName || 'image.jpg',
type: asset.mimeType || 'image/jpeg',
}));
actions.addFiles(files);
}
};

const pickDocuments = async () => {
const result = await DocumentPicker.getDocumentAsync({
multiple: true,
});

if (!result.canceled) {
const files = result.assets.map(asset => ({
uri: asset.uri,
name: asset.name,
type: asset.mimeType,
size: asset.size,
}));
actions.addFiles(files);
}
};

const takePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') return;

const result = await ImagePicker.launchCameraAsync({
quality: 0.8,
});

if (!result.canceled) {
const asset = result.assets[0];
actions.addFiles([{
uri: asset.uri,
name: `photo_${Date.now()}.jpg`,
type: 'image/jpeg',
}]);
}
};

const renderFile = ({ item }) => (
<View style={styles.fileItem}>
{item.preview && (
<Image source={{ uri: item.preview }} style={styles.thumbnail} />
)}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
</Text>
<Text style={styles.fileStatus}>
{item.status === 'uploading'
? `Uploading ${item.progress}%`
: item.status}
</Text>
</View>
<TouchableOpacity
onPress={() => actions.remove(item.id)}
style={styles.removeBtn}
>
<Text style={styles.removeText}>×</Text>
</TouchableOpacity>
</View>
);

return (
<View style={styles.container}>
{/* 액션 버튼 */}
<View style={styles.buttonRow}>
<TouchableOpacity style={styles.button} onPress={pickImages}>
<Text style={styles.buttonText}>Gallery</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={takePhoto}>
<Text style={styles.buttonText}>Camera</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={pickDocuments}>
<Text style={styles.buttonText}>Files</Text>
</TouchableOpacity>
</View>

{/* 파일 목록 */}
<FlatList
data={files}
keyExtractor={item => item.id}
renderItem={renderFile}
style={styles.list}
ListEmptyComponent={
<Text style={styles.emptyText}>No files selected</Text>
}
/>

{/* 업로드 버튼 */}
<TouchableOpacity
style={[styles.uploadBtn, state.isUploading && styles.uploadBtnDisabled]}
onPress={() => actions.upload()}
disabled={state.isUploading || files.length === 0}
>
{state.isUploading ? (
<View style={styles.uploadingRow}>
<ActivityIndicator color="white" />
<Text style={styles.uploadBtnText}>
Uploading {state.progress}%
</Text>
</View>
) : (
<Text style={styles.uploadBtnText}>
Upload {files.length} file(s)
</Text>
)}
</TouchableOpacity>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
backgroundColor: '#fff',
},
buttonRow: {
flexDirection: 'row',
gap: 12,
marginBottom: 16,
},
button: {
flex: 1,
backgroundColor: '#f0f0f0',
padding: 16,
borderRadius: 8,
alignItems: 'center',
},
buttonText: {
fontWeight: '600',
},
list: {
flex: 1,
},
fileItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderBottomWidth: 1,
borderBottomColor: '#eee',
},
thumbnail: {
width: 50,
height: 50,
borderRadius: 4,
marginRight: 12,
},
fileInfo: {
flex: 1,
},
fileName: {
fontWeight: '500',
},
fileStatus: {
color: '#666',
fontSize: 12,
marginTop: 2,
},
removeBtn: {
padding: 8,
},
removeText: {
fontSize: 24,
color: '#999',
},
emptyText: {
textAlign: 'center',
color: '#999',
marginTop: 40,
},
uploadBtn: {
backgroundColor: '#2196f3',
padding: 16,
borderRadius: 8,
alignItems: 'center',
marginTop: 16,
},
uploadBtnDisabled: {
backgroundColor: '#ccc',
},
uploadBtnText: {
color: 'white',
fontWeight: '600',
fontSize: 16,
},
uploadingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
});

플랫폼 차이점

기능React Native
드래그 앤 드롭아니오
파일 피커네이티브expo-image-picker
미리보기 URLObject URLs파일 URIs
파일 액세스File 객체URI 기반

Expo vs Bare React Native

Expo (관리형)

위의 모든 예제는 Expo 관리형 워크플로에서 작동합니다.

Bare React Native

Bare React Native의 경우 네이티브 모듈을 설치하세요:

# expo 패키지 대신
npm install react-native-image-picker
npm install react-native-document-picker

# 필요한 경우 링크 (이전 RN 버전)
npx react-native link

그런 다음 해당 패키지의 해당 API를 사용하세요.