🍔 핵심 내용
🥑 Tab Navigatior
하단 Tab Navigatior를 만들어 보자.
🍔 코드 리뷰
🥑 LoggedInNav.js
import React from "react";
import { Ionicons } from "@expo/vector-icons";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import Feed from "../screens/Feed";
import Search from "../screens/Search";
import Notifications from "../screens/Notifications";
import Profile from "../screens/Profile";
const Tabs = createBottomTabNavigator();
export default function LoggedInNav() {
return (
<Tabs.Navigator
tabBarOptions={{
activeTintColor: "white",
showLabel: false,
style: {
borderTopColor: "rgba(255, 255, 255, 0.3)",
backgroundColor: "black",
},
}}
>
<Tabs.Screen
name="Feed"
component={Feed}
options={{
tabBarIcon: ({ focused, color, size }) => (
<Ionicons name="home" color={color} size={focused ? 24 : 20} />
),
}}
/>
<Tabs.Screen
name="Search"
component={Search}
options={{
tabBarIcon: ({ focused, color, size }) => (
<Ionicons name="search" color={color} size={focused ? 24 : 20} />
),
}}
/>
<Tabs.Screen
name="Notifications"
component={Notifications}
options={{
tabBarIcon: ({ focused, color, size }) => (
<Ionicons name="heart" color={color} size={focused ? 24 : 20} />
),
}}
/>
<Tabs.Screen
name="Profile"
component={Profile}
options={{
tabBarIcon: ({ focused, color, size }) => (
<Ionicons name="person" color={color} size={focused ? 22 : 18} />
),
}}
/>
</Tabs.Navigator>
);
}
1) 하단 탭 생성 및 각 스크린들을 만들어주었다.
2) Ionicons의 아이콘 사용
화면이 지저분해 보이기 때문에, 아래와 같이 Ionicons 컴포넌트를 아래와 같이 만들어 주자.
🥑 TabIcon.js
import React from "react";
import { Ionicons } from "@expo/vector-icons";
export default function TabIcon({ iconName, color, focused }) {
return (
<Ionicons
name={focused ? iconName : `${iconName}-outline`}
color={color}
size={22}
/>
);
}
focused는 해당 탭을 터치 했을 때를 뜻한다. 여기서 해당 탭을 터치하면 일반 solid형식이되고 다른 곳을 터치하면 기존 탭은 outline으로 된다.
위와 같이 컴포넌트를 만든 후 다시 아래와 같이 정리
🥑 LoggedInNav.js
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import Feed from "../screens/Feed";
import Search from "../screens/Search";
import Notifications from "../screens/Notifications";
import Profile from "../screens/Profile";
import { View } from "react-native";
import TabIcon from "../components/nav/TabIcon";
const Tabs = createBottomTabNavigator();
export default function LoggedInNav() {
return (
<Tabs.Navigator
tabBarOptions={{
activeTintColor: "white",
showLabel: false,
style: {
borderTopColor: "rgba(255, 255, 255, 0.3)",
backgroundColor: "black",
},
}}
>
<Tabs.Screen
name="Feed"
component={Feed}
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"home"} color={color} focused={focused} />
),
}}
/>
<Tabs.Screen
name="Search"
component={Search}
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"search"} color={color} focused={focused} />
),
}}
/>
<Tabs.Screen
name="Camera"
component={View}
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"camera"} color={color} focused={focused} />
),
}}
/>
<Tabs.Screen
name="Notifications"
component={Notifications}
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"heart"} color={color} focused={focused} />
),
}}
/>
<Tabs.Screen
name="Profile"
component={Profile}
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"person"} color={color} focused={focused} />
),
}}
/>
</Tabs.Navigator>
);
}
🥑 screens/Feed.js
import React from "react";
import { Text, View } from "react-native";
export default function Feed() {
return (
<View
style={{
backgroundColor: "black",
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "white" }}>Feed</Text>
</View>
);
}
Feed 화면 세팅. 다른 화면들도 위와 같이 기본 세팅 진행.
🍔 핵심 내용
🥑 Tab + Stack 을 해보자.
기본 개념은, 각 탭 별로 공용으로 쓰이는 Stack을 만드는 것이다. 사실 각 탭도 스택으로 들어 간다.
탭이 A,B,C,D 이고 공용으로 쓰이는 것이c,d라면
A,c,d / B,c,d / C,c,d / D,c,d 이런식으로 되는 것이다. A,B,C,D도 스택으로 들어간다.
🍔 코드 리뷰
🥑 components/nav/StackNavFactory.js
import React from "react";
import { createStackNavigator } from "@react-navigation/stack";
import Photo from "../../screens/Photo";
import Profile from "../../screens/Profile";
import Feed from "../../screens/Feed";
import Search from "../../screens/Search";
import Notifications from "../../screens/Notifications";
import Me from "../../screens/Me";
const Stack = createStackNavigator();
export default function StackNavFactory({ screenName }) {
return (
<Stack.Navigator>
{screenName === "Feed" ? (
<Stack.Screen name={"Feed"} component={Feed} />
) : null}
{screenName === "Search" ? (
<Stack.Screen name={"Search"} component={Search} />
) : null}
{screenName === "Notifications" ? (
<Stack.Screen name={"Notifications"} component={Notifications} />
) : null}
{screenName === "Me" ? <Stack.Screen name={"Me"} component={Me} /> : null}
<Stack.Screen name="Profile" component={Profile} />
<Stack.Screen name="Photo" component={Photo} />
</Stack.Navigator>
);
}
LoggedInNav의 컴포넌트로 쓰일 스택 모음집이다. 위에 설명한 개념들이 들어가 있다.
Feed, Search, Notifications, Me 는 하단 탭들이고 Profile과 Photo는 공용 스택이다.
🥑 LoggedInNav.js
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import Feed from "../screens/Feed";
import Search from "../screens/Search";
import Notifications from "../screens/Notifications";
import { View } from "react-native";
import TabIcon from "../components/nav/TabIcon";
import Me from "../screens/Me";
import StackNavFactory from "../components/nav/StackNavFactory";
const Tabs = createBottomTabNavigator();
export default function LoggedInNav() {
return (
<Tabs.Navigator
tabBarOptions={{
activeTintColor: "white",
showLabel: false,
style: {
borderTopColor: "rgba(255, 255, 255, 0.3)",
backgroundColor: "black",
},
}}
>
<Tabs.Screen
name="Feed"
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"home"} color={color} focused={focused} />
),
}}
>
{() => <StackNavFactory screenName="Feed" />}
</Tabs.Screen>
<Tabs.Screen
name="Search"
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"search"} color={color} focused={focused} />
),
}}
>
{() => <StackNavFactory screenName="Search" />}
</Tabs.Screen>
<Tabs.Screen
name="Camera"
component={View}
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"camera"} color={color} focused={focused} />
),
}}
/>
<Tabs.Screen
name="Notifications"
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"heart"} color={color} focused={focused} />
),
}}
>
{() => <StackNavFactory screenName="Notifications" />}
</Tabs.Screen>
<Tabs.Screen
name="Me"
options={{
tabBarIcon: ({ focused, color, size }) => (
<TabIcon iconName={"person"} color={color} focused={focused} />
),
}}
>
{() => <StackNavFactory screenName="Me" />}
</Tabs.Screen>
</Tabs.Navigator>
);
}
BottomTab에 Stack 을 사용하는 방법이다. Tabs.Screen 사이에 {함수}를 통해 연결해준다.
🥑 screens/Me.js
import React from "react";
import { Text, View } from "react-native";
export default function Me() {
return (
<View
style={{
backgroundColor: "black",
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "white" }}>Me</Text>
</View>
);
}
BottomTab에 Profile 대신에 Me라고 바꿈.
🥑 screens/Photo.js
import React from "react";
import { Text, TouchableOpacity, View } from "react-native";
export default function Photo({ navigation }) {
return (
<View
style={{
backgroundColor: "black",
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<TouchableOpacity onPress={() => navigation.navigate("Profile")}>
<Text style={{ color: "white" }}>Profile</Text>
</TouchableOpacity>
</View>
);
}
스택 카드중에 하나인 Photo 이다. Profile이라는 버튼을 누르면 Profile 카드가 위로 올라온다. (아래 코드)
🥑 screens/Profile.js
import React from "react";
import { Text, View } from "react-native";
export default function Profile() {
return (
<View
style={{
backgroundColor: "black",
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "white" }}>Someones Profile</Text>
</View>
);
}
다른사람 프로필
🥑 screens/Search.js
import React from "react";
import { Text, TouchableOpacity, View } from "react-native";
export default function Search({ navigation }) {
return (
<View
style={{
backgroundColor: "black",
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<TouchableOpacity onPress={() => navigation.navigate("Photo")}>
<Text style={{ color: "white" }}>Photo</Text>
</TouchableOpacity>
</View>
);
}
1번 카드 Search에서 Photo를 누르면 2번 카드 Photo로 이동하고 2번 카드 Photo에서 Profile을 누르면 3번 카드 Profile이 나오는 식이다.
🥑 screens/Feed.js
import React from "react";
import { Text, TouchableOpacity, View } from "react-native";
export default function Feed({ navigation }) {
return (
<View
style={{
backgroundColor: "black",
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<TouchableOpacity onPress={() => navigation.navigate("Photo")}>
<Text style={{ color: "white" }}>Photo</Text>
</TouchableOpacity>
</View>
);
}
Feed 화면도 동일하게 적용해보았다.
🍔 핵심 내용
🥑 Feed화면에서 useQuery로 데이터 가지고 오기
1. 백엔드에 token을 넘겨 준 후 (사용자 인증 받기)
2. Feed 화면에 data를 가지고 오자.
🍔 코드 리뷰
🥑 navigators/SharedStackNav.js
import React from "react";
import { createStackNavigator } from "@react-navigation/stack";
import Photo from "../screens/Photo";
import Profile from "../screens/Profile";
import Feed from "../screens/Feed";
import Search from "../screens/Search";
import Notifications from "../screens/Notifications";
import Me from "../screens/Me";
import { Image } from "react-native";
const Stack = createStackNavigator();
export default function SharedStackNav({ screenName }) {
return (
<Stack.Navigator
headerMode="screen"
screenOptions={{
headerBackTitleVisible: false,
headerTintColor: "white",
headerStyle: {
shadowColor: "rgba(255, 255, 255, 0.3)",
backgroundColor: "black",
},
headerTitleAlign: "center",
}}
>
{screenName === "Feed" ? (
<Stack.Screen
name="Feed"
component={Feed}
options={{
headerTitle: () => (
<Image
style={{ width: 120, height: 40 }}
resizeMode="contain"
source={require("../assets/logo.png")}
/>
),
}}
/>
) : null}
{screenName === "Search" ? (
<Stack.Screen name="Search" component={Search} />
) : null}
{screenName === "Notifications" ? (
<Stack.Screen name="Notifications" component={Notifications} />
) : null}
{screenName === "Me" ? <Stack.Screen name="Me" component={Me} /> : null}
<Stack.Screen name="Profile" component={Profile} />
<Stack.Screen name="Photo" component={Photo} />
</Stack.Navigator>
);
}
1) 기존 StackNavFactory파일 경로를 navigators폴더로 바꿔줌 (navigator와 연관성이 높아)
2) Feed화면의 headerTitle의 글씨를 이미지로 바꿔주고 사이즈도 적당히 맞춰줌
🥑 apollo.js
import {
ApolloClient,
createHttpLink,
InMemoryCache,
makeVar,
} from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import AsyncStorage from "@react-native-async-storage/async-storage";
export const isLoggedInVar = makeVar(false);
export const tokenVar = makeVar("");
const TOKEN = "token";
export const logUserIn = async (token) => {
await AsyncStorage.setItem(TOKEN, token);
isLoggedInVar(true);
tokenVar(token);
};
export const logUserOut = async () => {
await AsyncStorage.removeItem(TOKEN);
isLoggedInVar(false);
tokenVar(null);
};
const httpLink = createHttpLink({
uri: "http://localhost:4000/graphql",
});
const authLink = setContext((_, { headers }) => {
return {
headers: {
...headers,
token: tokenVar(),
},
};
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});
export default client;
header의 token정보를 백엔드로 넘기는 과정을 추가해주었다. 이전 web에서 다루던 것과 동일하다.
(추가로 로그아웃 함수 하나 만듬)
🥑 Feed.js
import { gql, useQuery } from "@apollo/client";
import React from "react";
import { Text, View } from "react-native";
import { COMMENT_FRAGMENT, PHOTO_FRAGMENT } from "../fragments";
const FEED_QUERY = gql`
query seeFeed {
seeFeed {
...PhotoFragment
user {
username
avatar
}
caption
comments {
...CommentFragment
}
createdAt
isMine
}
}
${PHOTO_FRAGMENT}
${COMMENT_FRAGMENT}
`;
export default function Feed({ navigation }) {
const { data } = useQuery(FEED_QUERY);
console.log(data);
return (
<View
style={{
backgroundColor: "black",
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: "white" }}>Feed</Text>
</View>
);
}
Web에서 쓰던것과 동일하게, Feed 화면에 useQuery로 data를 가지고 와주자.
🥑 fragments.js
import { gql } from "@apollo/client";
export const PHOTO_FRAGMENT = gql`
fragment PhotoFragment on Photo {
id
file
likes
commentNumber
isLiked
}
`;
export const COMMENT_FRAGMENT = gql`
fragment CommentFragment on Comment {
id
user {
userName
avatar
}
payload
isMine
createdAt
}
`;
fragments 파일
🍔 핵심 내용
🥑 FlatList 개념
스크롤을 내리는 방법에는 2가지 방법이 있다.
ScrollView와, FlatList이다. ScrollView는 전체 data를 다 가지고 오는 것이고, FlatList는 딱 화면에 보이는 부분의 data만 가지고 오고 스크롤을 더 내려야 추가 data를 가지고 오는 형식이다.
(web상으로는 ScrollView가 괜찮지만 모바일에서는 FlatList가 거의 정석)
🍔 코드 리뷰
🥑 components/ScreenLayout.js
import React from "react";
import { ActivityIndicator, View } from "react-native";
export default function ScreenLayout({ loading, children }) {
return (
<View
style={{
backgroundColor: "black",
flex: 1,
alignItems: "center",
justifyContent: "center",
}}
>
{loading ? <ActivityIndicator color="white" /> : children}
</View>
);
}
각 스크린별 기본 레이아웃 컴포넌트를 만들었다.
🥑 Feed.js
import { gql, useQuery } from "@apollo/client";
import React from "react";
import { ActivityIndicator, FlatList, Text, View } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import ScreenLayout from "../components/ScreenLayout";
import { COMMENT_FRAGMENT, PHOTO_FRAGMENT } from "../fragments";
const FEED_QUERY = gql`
query seeFeed {
seeFeed {
...PhotoFragment
user {
username
avatar
}
caption
comments {
...CommentFragment
}
createdAt
isMine
}
}
${PHOTO_FRAGMENT}
${COMMENT_FRAGMENT}
`;
export default function Feed() {
const { data, loading } = useQuery(FEED_QUERY);
const renderPhoto = ({ item: photo }) => {
return (
<View style={{ flex: 1 }}>
<Text style={{ color: "white" }}>{photo.caption}</Text>
</View>
);
};
return (
<ScreenLayout loading={loading}>
<FlatList
data={data?.seeFeed}
keyExtractor={(photo) => "" + photo.id}
renderItem={renderPhoto}
/>
</ScreenLayout>
);
}
ScreenLayout 적용과 FlatList 적용 방법
🍔 핵심 내용
🥑 Photo 화면을 꾸며주자.
1. Photo 화면에 추가적으로 Stack 화면을 추가할 것이다. (Likes화면 Comment화면)
🍔 코드 리뷰
🥑 Photo.js
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import styled from "styled-components/native";
import { Image, useWindowDimensions } from "react-native";
import { TouchableOpacity } from "react-native-gesture-handler";
const Container = styled.View``;
const Header = styled.TouchableOpacity`
padding: 10px;
flex-direction: row;
align-items: center;
`;
const UserAvatar = styled.Image`
margin-right: 10px;
width: 25px;
height: 25px;
border-radius: 12.5;
`;
const Username = styled.Text`
color: white;
font-weight: 600;
`;
const File = styled.Image``;
const Actions = styled.View`
flex-direction: row;
align-items: center;
`;
const Action = styled.TouchableOpacity`
margin-right: 10px;
`;
const Caption = styled.View`
flex-direction: row;
`;
const CaptionText = styled.Text`
color: white;
margin-left: 5px;
`;
const Likes = styled.Text`
color: white;
margin: 7px 0px;
font-weight: 600;
`;
const ExtraContainer = styled.View`
padding: 10px;
`;
function Photo({ id, user, caption, file, isLiked, likes }) {
const navigation = useNavigation();
const { width, height } = useWindowDimensions();
const [imageHeight, setImageHeight] = useState(height - 450);
useEffect(() => {
Image.getSize(file, (width, height) => {
setImageHeight(height / 3);
});
}, [file]);
return (
<Container>
<Header onPress={() => navigation.navigate("Profile")}>
<UserAvatar resizeMode="cover" source={{ uri: user.avatar }} />
<Username>{user.userName}</Username>
</Header>
<File
resizeMode="cover"
style={{
width,
height: imageHeight,
}}
source={{ uri: file }}
/>
<ExtraContainer>
<Actions>
<Action>
<Ionicons
name={isLiked ? "heart" : "heart-outline"}
color={isLiked ? "tomato" : "white"}
size={22}
/>
</Action>
<Action onPress={() => navigation.navigate("Comments")}>
<Ionicons name="chatbubble-outline" color="white" size={22} />
</Action>
</Actions>
<TouchableOpacity onPress={() => navigation.navigate("Likes")}>
<Likes>{likes === 1 ? "1 like" : `${likes} likes`}</Likes>
</TouchableOpacity>
<Caption>
<TouchableOpacity onPress={() => navigation.navigate("Profile")}>
<Username>{user.userName}</Username>
</TouchableOpacity>
<CaptionText>{caption}</CaptionText>
</Caption>
</ExtraContainer>
</Container>
);
}
Photo.propTypes = {
id: PropTypes.number.isRequired,
user: PropTypes.shape({
avatar: PropTypes.string,
userName: PropTypes.string.isRequired,
}),
caption: PropTypes.string,
file: PropTypes.string.isRequired,
isLiked: PropTypes.bool.isRequired,
likes: PropTypes.number.isRequired,
commentNumber: PropTypes.number.isRequired,
};
export default Photo;
1) Photo.js에서 다른 화면으로 옮기기 위해서는 useNavigation 훅을 이용 한다.
🥑 SharedStackNav.js
import React from "react";
import { createStackNavigator } from "@react-navigation/stack";
import Photo from "../screens/Photo";
import Profile from "../screens/Profile";
import Feed from "../screens/Feed";
import Search from "../screens/Search";
import Notifications from "../screens/Notifications";
import Me from "../screens/Me";
import { Image } from "react-native";
import Likes from "../screens/Likes";
import Comments from "../screens/Comments";
const Stack = createStackNavigator();
export default function SharedStackNav({ screenName }) {
return (
<Stack.Navigator
headerMode="screen"
screenOptions={{
headerBackTitleVisible: false,
headerTintColor: "white",
headerStyle: {
borderBottomColor: "rgba(255, 255, 255, 0.3)",
shadowColor: "rgba(255, 255, 255, 0.3)",
backgroundColor: "black",
},
}}
>
{screenName === "Feed" ? (
<Stack.Screen
name={"Feed"}
component={Feed}
options={{
headerTitle: () => (
<Image
style={{
width: 120,
height: 40,
}}
resizeMode="contain"
source={require("../assets/logo.png")}
/>
),
}}
/>
) : null}
{screenName === "Search" ? (
<Stack.Screen name={"Search"} component={Search} />
) : null}
{screenName === "Notifications" ? (
<Stack.Screen name={"Notifications"} component={Notifications} />
) : null}
{screenName === "Me" ? <Stack.Screen name={"Me"} component={Me} /> : null}
<Stack.Screen name="Profile" component={Profile} />
<Stack.Screen name="Photo" component={Photo} />
<Stack.Screen name="Likes" component={Likes} />
<Stack.Screen name="Comments" component={Comments} />
</Stack.Navigator>
);
}
1) Likes, Comments 화면 추가
🍔 핵심 내용
🥑 refresh 기능 (화면 위에서 아래로 끌었을 때)
1 보통 리프레쉬 하려면 모바일 화면 위에서 아래로 쓸어 내린다. 이 기능을 만들어 보자.
🍔 코드 리뷰
🥑 Feed.js
import { gql, useQuery } from "@apollo/client";
import React from "react";
import { useState } from "react";
import { FlatList, Text, View } from "react-native";
import Photo from "./Photo";
import ScreenLayout from "../components/ScreenLayout";
import { COMMENT_FRAGMENT, PHOTO_FRAGMENT } from "../fragments";
const FEED_QUERY = gql`
query seeFeed {
seeFeed {
...PhotoFragment
user {
userName
avatar
}
caption
comments {
...CommentFragment
}
createdAt
isMine
}
}
${PHOTO_FRAGMENT}
${COMMENT_FRAGMENT}
`;
export default function Feed() {
const { data, loading, refetch } = useQuery(FEED_QUERY);
const renderPhoto = ({ item: photo }) => {
return <Photo {...photo} />;
};
const refresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const [refreshing, setRefreshing] = useState(false);
return (
<ScreenLayout loading={loading}>
<FlatList
refreshing={refreshing}
onRefresh={refresh}
style={{ width: "100%" }}
showsVerticalScrollIndicator={false}
data={data?.seeFeed}
keyExtractor={(photo) => "" + photo.id}
renderItem={renderPhoto}
/>
</ScreenLayout>
);
}
1) FlatList에 refreshing과 onRefresh props를 추가 했다.
🍔 핵심 내용
🥑 무한 스크롤 기능을 만들어 보자. (Infinite Scrolling)
1. 기본 개념은 일단 백엔드에서 배웠던 take와 skip을 이용해서 일부 data만 받아 온 후, 모바일에서 특정 화면이 넘어가면 추가로 query를 하여 기존 data + 새로운 data를 얹혀줘서 화면에 뿌려주는 식으로 만든다.
🍔 코드 리뷰
<백엔드 부분 수정>
🥑 seeFeed.typeDefs.js
import { gql } from "apollo-server-core";
export default gql`
type Query {
seeFeed(offset: Int!): [Photo]
}
`;
offset 인자 추가
🥑 seeFeed.resolvers.js
import client from "../../client";
import { protectedResolver } from "../../users/users.utils";
export default {
Query: {
seeFeed: protectedResolver(async (_, { offset }, { loggedInUser }) =>
client.photo.findMany({
take: 2,
skip: offset,
where: {
OR: [
{
user: {
followers: {
some: {
id: loggedInUser.id,
},
},
},
},
{
userId: loggedInUser.id,
},
],
},
orderBy: {
createdAt: "desc",
},
})
),
},
};
1) offset 인자 추가
2) take - 2개씩만 가지고 온다 / skip 해당 수치 만큼 데이터 가지고 오는 것을 스킵 한다.
<RN>
🥑 Feed.js
import { gql, useQuery } from "@apollo/client";
import React from "react";
import { useState } from "react";
import { FlatList, Text, View } from "react-native";
import Photo from "../components/Photo";
import ScreenLayout from "../components/ScreenLayout";
import { COMMENT_FRAGMENT, PHOTO_FRAGMENT } from "../fragments";
const FEED_QUERY = gql`
query seeFeed($offset: Int!) {
seeFeed(offset: $offset) {
...PhotoFragment
user {
username
avatar
}
caption
comments {
...CommentFragment
}
createdAt
isMine
}
}
${PHOTO_FRAGMENT}
${COMMENT_FRAGMENT}
`;
export default function Feed() {
const { data, loading, refetch, fetchMore } = useQuery(FEED_QUERY, {
variables: {
offset: 0,
},
});
const renderPhoto = ({ item: photo }) => {
return <Photo {...photo} />;
};
const refresh = async () => {
setRefreshing(true);
await refetch();
setRefreshing(false);
};
const [refreshing, setRefreshing] = useState(false);
return (
<ScreenLayout loading={loading}>
<FlatList
onEndReachedThreshold={0.05}
onEndReached={() =>
fetchMore({
variables: {
offset: data?.seeFeed?.length,
},
})
}
refreshing={refreshing}
onRefresh={refresh}
style={{ width: "100%" }}
showsVerticalScrollIndicator={false}
data={data?.seeFeed}
keyExtractor={(photo) => "" + photo.id}
renderItem={renderPhoto}
/>
</ScreenLayout>
);
}
1) offset 인자 값으로 초기에 0으로 준다.
2) FlatList props 값에 onEndReached의 역할은 화면의 마지막 부분에 닿이면 무엇을 실행 할 것인지 정하는 것이다.
여기서는, fetchMore 함수를 통해 추가로 fetch를 해줄 것인데, 인자 값인 offset의 값은 현재 seeFeed의 양만큼이다. 다시 말해, 화면 초기에는 data를 2개를 가지고와서, offset값은 2개가 들어가게되어 추가로 fetch 되는 값은 3번째 값부터 오게 된다. 이렇게 무한으로 흘러 가는 식이다.
그리고 onEndReachedThreshold은 화면의 마지막의 위치를 수치상으로 어디로 할지 정하는 것이다. (숫자카 커질수록 아래서부터 멀어지는 것임, 0이면 완전 바닥) ** Threshold 는 문 턱이라는 뜻
🥑 apollo.js
import { offsetLimitPagination } from "@apollo/client/utilities";
...
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
seeFeed: offsetLimitPagination(),
},
},
},
}),
});
export default client;
...
cache 값에 위와 같이 설정을 해두어야 한다. 쉽게 얘기하면, seeFeed query 값에 대해서는 기존 배열 값 + 새로운 배열 값이 적용 된다는 개념이다.
🍔 핵심 내용
🥑 cache persist 기능 적용하기 / Likes 버튼 실시간 적용
1.와이파이가 off 되어 있더라도, 기존 data가 cache에 저장 되어있으면 화면들을 볼 수가 있다. 해당 기능을 만들어 보자.
2. web에서 적용했던 Likes 버튼 기능을 만들어보자.
npm install apollo3-cache-persist 설치
🍔 코드 리뷰
🥑 components/Photo.js
import React, { useEffect, useState } from "react";
import PropTypes from "prop-types";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import styled from "styled-components/native";
import { Image, useWindowDimensions } from "react-native";
import { TouchableOpacity } from "react-native-gesture-handler";
import { gql, useMutation } from "@apollo/client";
const TOGGLE_LIKE_MUTATION = gql`
mutation toggleLike($id: Int!) {
toggleLike(id: $id) {
ok
error
}
}
`;
const Container = styled.View``;
const Header = styled.TouchableOpacity`
padding: 10px;
flex-direction: row;
align-items: center;
`;
const UserAvatar = styled.Image`
margin-right: 10px;
width: 25px;
height: 25px;
border-radius: 12.5px;
`;
const Username = styled.Text`
color: white;
font-weight: 600;
`;
const File = styled.Image``;
const Actions = styled.View`
flex-direction: row;
align-items: center;
`;
const Action = styled.TouchableOpacity`
margin-right: 10px;
`;
const Caption = styled.View`
flex-direction: row;
`;
const CaptionText = styled.Text`
color: white;
margin-left: 5px;
`;
const Likes = styled.Text`
color: white;
margin: 7px 0px;
font-weight: 600;
`;
const ExtraContainer = styled.View`
padding: 10px;
`;
function Photo({ id, user, caption, file, isLiked, likes }) {
const navigation = useNavigation();
const { width, height } = useWindowDimensions();
const [imageHeight, setImageHeight] = useState(height - 450);
useEffect(() => {
Image.getSize(file, (width, height) => {
setImageHeight(height / 3);
});
}, [file]);
const updateToggleLike = (cache, result) => {
const {
data: {
toggleLike: { ok },
},
} = result;
if (ok) {
const photoId = `Photo:${id}`;
cache.modify({
id: photoId,
fields: {
isLiked(prev) {
return !prev;
},
likes(prev) {
if (isLiked) {
return prev - 1;
}
return prev + 1;
},
},
});
}
};
const [toggleLikeMutation] = useMutation(TOGGLE_LIKE_MUTATION, {
variables: {
id,
},
update: updateToggleLike,
});
return (
<Container>
<Header onPress={() => navigation.navigate("Profile")}>
<UserAvatar resizeMode="cover" source={{ uri: user.avatar }} />
<Username>{user.username}</Username>
</Header>
<File
resizeMode="cover"
style={{
width,
height: imageHeight,
}}
source={{ uri: file }}
/>
<ExtraContainer>
<Actions>
<Action onPress={toggleLikeMutation}>
<Ionicons
name={isLiked ? "heart" : "heart-outline"}
color={isLiked ? "tomato" : "white"}
size={22}
/>
</Action>
<Action onPress={() => navigation.navigate("Comments")}>
<Ionicons name="chatbubble-outline" color="white" size={22} />
</Action>
</Actions>
<TouchableOpacity onPress={() => navigation.navigate("Likes")}>
<Likes>{likes === 1 ? "1 like" : `${likes} likes`}</Likes>
</TouchableOpacity>
<Caption>
<TouchableOpacity onPress={() => navigation.navigate("Profile")}>
<Username>{user.username}</Username>
</TouchableOpacity>
<CaptionText>{caption}</CaptionText>
</Caption>
</ExtraContainer>
</Container>
);
}
Photo.propTypes = {
id: PropTypes.number.isRequired,
user: PropTypes.shape({
avatar: PropTypes.string,
username: PropTypes.string.isRequired,
}),
caption: PropTypes.string,
file: PropTypes.string.isRequired,
isLiked: PropTypes.bool.isRequired,
likes: PropTypes.number.isRequired,
commentNumber: PropTypes.number.isRequired,
};
export default Photo;
1. Likes 버튼에 토글 기능 추가
🥑 apollo.js
...
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
seeFeed: offsetLimitPagination(),
},
},
},
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache,
});
...
cache를 밖으로 export 하기 위해 밖으로 빼놓았다.
🥑 App.js
import AppLoading from "expo-app-loading";
import React, { useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import * as Font from "expo-font";
import { Asset } from "expo-asset";
import LoggedOutNav from "./navigators/LoggedOutNav";
import { NavigationContainer } from "@react-navigation/native";
import { ApolloProvider, useReactiveVar } from "@apollo/client";
import client, { isLoggedInVar, tokenVar, cache } from "./apollo";
import LoggedInNav from "./navigators/LoggedInNav";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AsyncStorageWrapper, persistCache } from "apollo3-cache-persist";
export default function App() {
const [loading, setLoading] = useState(true);
const onFinish = () => setLoading(false);
const isLoggedIn = useReactiveVar(isLoggedInVar);
const preloadAssets = () => {
const fontsToLoad = [Ionicons.font];
const fontPromises = fontsToLoad.map((font) => Font.loadAsync(font));
const imagesToLoad = [require("./assets/logo.png")];
const imagePromises = imagesToLoad.map((image) => Asset.loadAsync(image));
return Promise.all([...fontPromises, ...imagePromises]);
};
const preload = async () => {
const token = await AsyncStorage.getItem("token");
if (token) {
isLoggedInVar(true);
tokenVar(token);
}
await persistCache({
cache,
storage: new AsyncStorageWrapper(AsyncStorage),
});
return preloadAssets();
};
if (loading) {
return (
<AppLoading
startAsync={preload}
onError={console.warn}
onFinish={onFinish}
/>
);
}
return (
<ApolloProvider client={client}>
<NavigationContainer>
{isLoggedIn ? <LoggedInNav /> : <LoggedOutNav />}
</NavigationContainer>
</ApolloProvider>
);
}
preload 부분에 적용 하였다.