여러 탭을 만든다. 스택과 마찬가지로 Tabs.Navigator
안에 필요한 Tabs.Screen
넣는다. Screen
전체에 일괄적으로 옵션을 적용하기 위해선 Navigator
에 screenOptions
를 , 개별적으로 options
를 Screen
의 props로 넣는다. 여러 옵션들은 다음 공식 문서에서 확인할 수 있다.
탭에 아이콘을 넣기 위해선 tabBarIcon
이라는 옵션을 사용한다. 컴포넌트를 리턴하는 함수를 받는다. 함수의 매개 변수로 다음과 같은 값을 받는다.
tabBarActiveTintColor
와 같다.expo vector-icon 목록
함수가 반복적으로 사용되기 때문에, 재사용 가능한 컴포넌트로 만들면 좋다.
// TabIcon.tsx
...
export default function TabIcon({ focused, iconName, color }: TabIconProps) {
return (
<Ionicons
name={focused ? iconName : `${iconName}-outline`}
color={color}
size={focused ? 24 : 20}
/>
);
}
// LoggedInNav.tsx
...
function LoggedInNav() {
return (
<Tabs.Navigator ... />
<Tabs.Screen
...
options={{
tabBarIcon: ({ focused, color, size }) => (
// 컴포넌트화 전
<Ionicons name={focused ? "home" : "home-outline"}
color={color} size={focused ? 24 : 20} />
),
}}
/>
<Tabs.Screen
...
options={{
tabBarIcon: ({ focused, color, size }) => (
// 컴포넌트화 후
<TabIcon focused={focused} color={color} iconName="search" />
),
}}
/>
...
인스타그램 앱의 구성을 생각하면 각 탭 안에 여러 스택이 있는 구조다. 각 탭을 누를 시, 가장 먼저 올라오는 스택이 해당 탭의 페이지고 페이지 안에서 사진이나 프로필을 클릭하면 또 다른 스택으로 이동하게 된다.
이 구조를 코드로 만드려면 탭(Tabs.Screen
) 안에 스택 컴포넌트(SharedStackNav
)를 리턴하는 함수를 children으로 보내야한다.
// LoggedInNav.tsx
...
function LoggedInNav() {
return (
<Tabs.Navigator ...>
<Tabs.Screen
name="RootFeed"
...
>
{() => <StackNavFactory screenName="Feed" />}
</Tabs.Screen>
...
SharedStackNav
컴포넌트 안에서 props로 전달 받은 screenName
에 따라, 해당되는 스택 (탭의 첫 화면)을 리턴해준다. Profile
과 Photo
모든 탭에서 공용(shared)으로 사용되는 스택이다.
// SharedStackNav.tsx
...
const getFirstScreen = (screenName: string) => {
if (screenName === "Feed") {
return <Stack.Screen name="Feed" component={Feed} />;
} else if (screenName === "Search") {
return <Stack.Screen name="Search" component={Search} />;
} else if (screenName === "Notifications") {
return <Stack.Screen name="Notifications" component={Notifications} />;
} else if (screenName === "Me") {
return <Stack.Screen name="Me" component={Me} />;
} else {
return null;
}
};
function SharedStackNav({ screenName }: SharedStackNavProps) {
return (
<Stack.Navigator ...>
{getFirstScreen(screenName)} // ** 탭의 첫 번째 화면
<Stack.Screen name="Profile" component={Profile} />
<Stack.Screen name="Photo" component={Photo} />
</Stack.Navigator>
);
}
Error : Found screens with the same name nested inside one another. Check:
부모와 자식 컴포넌트의 name이 같을 때 생기는 에러, 다르게 바꿔주자.
Feed 스크린의 타이틀을 인스타그램 로고로 바꾼다. headerTitle
이라는 옵션으로 가능하다. String
이나 React.Node
가 들어간다. Image
와 함께 사이즈를 header에 맞춘다.
// SharedStackNav.tsx
...
const getFirstScreen = (screenName: string) => {
if (screenName === "Feed") {
return (
<Stack.Screen
...
options={{
headerMode: "screen",
headerTitle: () => (
<Image
style={{ maxHeight: 40, maxWidth: 120 }}
resizeMode="contain"
source={require("../assets/logo.png")}
/>
),
}}
/>
);
...
};
...
Web에서와 같이 모든 요청 헤더에 토큰이 담길 수 있도록 한다.
FEED (Header - setContext in Apollo Client 참고)
다만, 차이점이 있다면 웹에서는 토큰을 로컬 스토리지에서 가져와 헤더에 담았지만 네이티브에서는 Reactive Variables 변수에 담긴 토큰을 가져온다.
// Apollo.ts
...
const AuthLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
token: tokenVar(), // 현재 tokenVar에 저장된 토큰을 불러옴
},
};
});
Native에서 View
자체로만 스크롤 할 수 없다. ScrollView
라는 컴포넌트 안에 화면보다 큰 View
가 있으면 스크롤이 가능하다.
// 상하 스크롤
...
return (
<View ...>
<ScrollView>
<View style={{ height: 20000, flex: 1, backgroundColor: "blue" }}>
<Text>I'm super big</Text>
</View>
</ScrollView>
</View>
);
// 좌우 스크롤
...
return (
<View ...>
<ScrollView horizontal>
<View style={{ width: 20000, flex: 1, backgroundColor: "blue" }}>
<Text>I'm super big</Text>
</View>
</ScrollView>
</View>
);
}
하지만, 앱의 성능을 고려하면 엄청나게 많은 데이터를 ScrollView
로 한꺼번에 렌더시키면 좋지 않다. 이럴 때는 컴포넌트가 화면 상에 있으면 렌더링하고 없으면 렌더링 하지 않는 lazy loading이 필요하다. FlatList
컴포넌트로 이 기능을 쉽게 구현할 수 있다.
FlatList
는 3가지 props를 요구한다.
function Feed({ navigation }: FeedProps) {
const { data, loading } = useQuery(FEED_QUERY);
const renderPhoto: ListRenderItem<seeFeed_seeFeed || null> =
({ item: photo }) => (
<View style={{ flex: 1 }}>
<Text style={{ color: "white" }}>{photo.caption}</Text>
</View>
);
return (
<ScreenLayout loading={loading}>
<FlatList
data={data?.seeFeed}
// string
keyExtractor={(photo) => "" + photo.id}
renderItem={renderPhoto}
/>
</ScreenLayout>
);
}
네이티브에서 이미지를 렌더하기 위해, 필수적으로 width와 height 값을 지정해주어야 한다. 너비와 높이를 임의로 지정하면 자칫 이미지의 비율이 이상해질 수도 있다. 인스타그램에서 너비는 화면의 너비와 같게 만든다. UseWindowDimensions
메서드로 화면 너비와 높이를 구할 수 있다.
이미지 파일의 실제 사이즈를 가져올 수 있다. 3가지 인자를 받는다.
위 두 메서드를 이용하여 사진을 크기를 보기 좋게 렌더시킨다. 너비는 화면의 너비, 높이는 실제 이미지의 높이를 구한 다음 너무 클 경우를 대비하여 3으로 나누어준다.
// Photo.tsx
...
function Photo(...) {
const { width, height } = useWindowDimensions();
const [imageHeight, setImageHeight] = useState(height - 450);
useEffect(() => {
Image.getSize(file, (width, height) => {
setImageHeight(height / 3);
});
}, [file]);
return (
<Container>
...
<File
resizeMode="cover"
style={{ width, height: imageHeight }}
source={{ uri: file }}
/>
...
</Container>
);
}
Photo 컴포넌트에서 프로필, 댓글, 좋아요 등 눌렀을 때 해당 스크린으로 이동할 수 있게 만든다. 하지만, Photo는 스크린 위에 있는 컴포넌트이기 때문에, navigation prop을 갖고 있지 않다. 두 가지 방법이 있다.
useNavigation
사용하기
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
FlatList
컴포넌트의 refreshing
, onRefresh
props와 useQuery
의 refetch
를 사용한다.
refetch
는 query를 다시 호출하는 함수다. refreshing
을 상태로 만들어 refetch
후 다시 새로고침을 종료하는 함수를 만들어 당겨서 새로고침을 구현한다.
// Feed.tsx
...
function Feed(...) {
const { ..., refetch } = useQuery(FEED_QUERY);
const refresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const [refreshing, setRefreshing] = useState(false);
return (
<ScreenLayout loading={loading}>
<FlatList
refreshing={refreshing}
onRefresh={refresh}
showsVerticalScrollIndicator={false} // 스크롤바 숨기기
/>
...
</ScreenLayout>
);
}
클라이언트에서 스크롤이 끝에 도달했을 때 쯤, 서버에 seeFeed 요청을 하여 계속해서 데이터를 받아온다. 이미 화면에 띄워진 데이터의 인덱스는 offset
이라는 값으로 저장하여, 인덱스 이후의 데이터를 2개씩만 받아온다.
그럼 2개 중 마지막 사진의 끝에 도달할 때 쯤, 다시 offset
을 서버에 전달하여 다음 2개의 사진을 다시 받아온다. 이 과정을 보여줄 사진이 더 이상 없을 때 까지 무한 반복하는 기능이 Infinite Scrolling이다.
offset
이라는 값을 받아 2개의 사진을 차례대로 보내는 코드를 작성한다.
// SeeFeed.resolvers.ts
...
const resolvers: Resolvers = {
Query: {
seeFeed: protectedResolver((_, { offset }, { loggedInUser }) =>
// photo를 찾을 때, 팔로워 목록에 내 이름이 있는 유저들의 photo를 찾음
client.photo.findMany({
take: 2, // 찾을 photo의 개수
skip: offset, // 나머지 하나는 offset으로 표시 안함
...
// SeeFeed.typeDefs.ts
...
type Query {
seeFeed(offset: Int!): [Photo] // offset variables 추가
}
...
클라이언트에서 특정 스크롤 위치에서 계속하여 seeFeed 요청을 보낸다. FlatList
의 onEndReached
, onEndReacedThreshold
와 useQuery
의 fetchMore
을 이용하여 구현한다. 다음과 같은 기능을 한다.
// Feed.tsx
...
export const FEED_QUERY = gql`
query seeFeed($offset: Int!) {
seeFeed(offset: $offset) {
...
}
...
`;
function Feed(...) {
const { ..., fetchMore } = useQuery<
seeFeed,
seeFeedVariables
>(FEED_QUERY, {
variables: { offset : 0 }, // 초기 값 : 0번째 사진부터
});
...
return (
<ScreenLayout loading={loading}>
<FlatList
onEndReached={() =>
fetchMore({
variables: {
// 지금까지 받아온 피드의 수 만큼 스킵
offset: data?.seeFeed?.length,
},
})
}
onEndReachedThreshold={0.02}
...
/>
</ScreenLayout>
);
}
이제 서버로부터 새로운 피드를 계속해서 받아오지만 화면 상으로는 아무런 변화가 없다. Component나 state의 변화가 전혀 없기 때문에, 아무것도 렌더링 되지 않는 것이다.
Apollo cache에 타입을 설정해준다. Apollo가 query를 전달인자(offset)에 따라 독립된 공간에 저장하고 있기 때문에 리스트가 렌더링 되지 않고 있다.
FlatList : [seeFeed.offset : 0] // 실제 렌더링 되는 리스트
[seeFeed.offset : 2] // 계속해서 피드를 새로 받고있지만 업데이트 되지 않음
[seeFeed.offset : 4]
[seeFeed.offset : 6]
typePolices를 설정하여 seeFeed에 한해 query를 전달인자에 따라 구별시키는 걸 막아준다. 새로운 피드가 들어오면 기존 피드와 결합되는 식으로 fetchMore
가 이루어지도록 만든다.
// apollo.ts
...
const client = new ApolloClient({
...
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
// seeFeed에 한하여,
seeFeed: {
// 전달인자에 따라 cache를 구별하지 않는다.
keyArgs: false,
// 새로운 데이터를 어떻게 처리해야하는지 알려줌
// 기존 데이터나 유입 데이터 둘 중 하나만 존재할 수 있으니 빈 배열이 기본 값
merge: (existing = [], incoming = []) => [...existing, ...incoming],
},
...
위 과정을 네이티브 내장 함수 offsetLimitPagination
으로도 쉽게 설정 가능하다.
// apollo.ts
...
typePolicies: {
Query: {
fields: {
seeFeed: offsetLimitPagination(),
...
서버와 연결이 끊기더라도, Cache에 저장된 데이터로 제한된 영역 안에서 앱 사용이 가능하다. Cache를 persistCache
를 이용하여 AsyncStorage에 저장한 후, AppLoading 시 불러오면 된다.
Cache를 preload 시에 불러올 수 있도록 전역으로 export 해준다.
// apollo.ts
export const cache = new InMemoryCache({ ... });
const client = new ApolloClient({
link: AuthLink.concat(httpLink),
cache,
});
preload 시, 저장해놓은 cache를 불러온다. 그리고 또 새로운 cache는 AsyncStorage
에 저장될 수 있도록 설정해준다. ApolloProvider
가 초기화 되기 전에, cache를 미리 불러와야한다.
// App.tsx
const preload = async () => {
...
await persistCache({
cache,
storage: new AsyncStorageWrapper(AsyncStorage),
});
return ...
};
if (loading) {
return (
<AppLoading
startAsync={preload}
...
/>
);
}
return (
<ApolloProvider client={client}>
...
</ApolloProvider>
);
}