Criando um app de lista de tarefas em React Native
Olá, turma! Hoje vamos construir um app massinha em React Native. Ele é bem simples mesmo e é para você que quer começar nessa nova linguagem. Nele vamos aprender sobre componentização, mudança de estados e tudo o mais.
O nosso projeto se chamará todoList. Nada mais é que uma lista de tarefas. Nele poderemos adicionar uma tarefa e marcá-la como concluída.
Esse projeto é um desafio que tinha lá no curso da Rocketseat e eu me propus a fazer. E para deixar o desafio mais interessante, resolvi documentar tudo e criar esse tutorial para consolidar os meus conhecimentos. Bom, bora codar.
Iniciando o projeto
Para começar, no terminal e na pasta em que desejo criar o projeto, utilizo o comando:
expo init todolist --npm
Eu escolhi começar com a opção ‘blank (TypeScript)’.
Depois de instalado todas as dependências, podemos iniciar o projeto no vsCode (que é a ferramenta que eu uso, mas você pode usar outra de sua preferência).
code todolist
Vamos começar então removendo todo o conteúdo da função App e estruturando as nossas pastas. Eu gosto de deixar tudo de novo na pasta ‘src’. Src vem de ‘Source’, fonte em inglês. Nela então vai ficar todo o nosso código FONTE. Faz sentido, não faz?
Dentro da pasta src, criamos outra pasta chamada ‘screens’, nessa pasta ficarão as nossas telas. Aproveitando, vamos criar a nossa tela principal, e para isso eu gosto de organizar também em pastas. Criamos então uma pasta chamada Home e dentro dela colocamos dois arquivos: index.tsx
que será o nosso componente de fato e o styles.ts
que é onde ficará a estilização do nosso componente
Criando nosso componente Input
Bora então desenvolver o nosso app, começando a pensar nos componentes que podemos criar.
Vamos começar pelo Input. Eu criei uma nova pasta dentro de ‘src’ chamada ‘components’ para colocar todos os componentes da aplicação. Nela, criei a pasta AddNewTodo, com o arquivo index.tsx e styles.ts para manter o padrão.
O componente final ficou assim:
import { TextInput, TouchableOpacity, View, Text } from "react-native";
import { styles } from "./styles";
export function AddNewTodo(){
return (
<View style={styles.container}>
<TextInput
style={styles.inputField}
placeholder="Adicione uma nova tarefa"
placeholderTextColor="#808080"
/>
<TouchableOpacity
style={styles.button
}>
<View style={styles.iconButton}>
<Text style={styles.textButton}>+</Text>
</View>
</TouchableOpacity>
</View>
)
}
Eu envolvi tanto o Input quanto o Botão (TouchableOpacity) em um container (View). Dentro do botão eu poderia simplesmente adicionar um ícone, mas resolvi usar os recursos do próprio react e criei outro container e um texto dentro dele.
A estilização ficou assim:
import { StyleSheet } from "react-native";
export const styles = StyleSheet.create({
container: {
flexDirection: 'row',
marginHorizontal: 24
},
inputField: {
flex: 1,
height: 54,
padding: 16,
backgroundColor: '#262626',
borderRadius: 6,
marginRight: 4,
borderStyle: 'solid',
borderWidth: 1,
borderColor: '#0D0D0D',
color: '#F2F2F2',
fontSize: 16,
},
button: {
width: 52,
borderRadius: 6,
backgroundColor: '#1E6F9F',
justifyContent: 'center',
alignItems: 'center',
},
iconButton: {
width: 18,
height: 18,
borderColor: '#F2F2F2',
borderStyle: 'solid',
borderWidth: 1.5,
borderRadius: 50,
justifyContent: 'center',
alignItems: 'center',
alignContent: 'center'
},
textButton: {
lineHeight: 16,
color: '#F2F2F2',
}
})
Agora, na nossa home, vamos criar o nosso cabeçalho, mas sem a imagem por enquanto:
export function Home(){
return <View style={styles.header}/>
}
A estilização ficou dessa forma:
export const styles = StyleSheet.create({
header: {
width: '100%',
height: 173,
backgroundColor: '#0D0D0D'
}
})
Vamos aproveitar e colocar também aqui dentro a estilização do nosso corpo. Basta adicionar o código:
body: {
flex: 1,
backgroundColor: '#1A1A1A'
}
O flex 1 vai fazer que o corpo ocupe todo o espaço disponível que sobrou. Agora, na nossa Home, podemos adicionar o componente que criamos.
export function Home(){
return (
<>
<View style={styles.header}/>
<View style={styles.body}>
<AddNewTodo/>
</View>
</>
)
}
Note que adicionamos uma tag vazia ‘<>’ para embrulhar o nosso código. Isso é o Fragment do React que não tem nenhuma estilização e serve apenas envolver os elementos filhos para não dar erro na nossa aplicação. Para renderizar o componente corretamente, é preciso retornar sempre um único elemento pai.
O AddNewTodo foi adicionado dentro do nosso corpo, para que seja possível aparecer por cima do nosso cabeçalho. Fazemos isso modificando a estilização:
container: {
position: 'absolute',
top: -30,
flexDirection: 'row',
marginHorizontal: 24
},
Para finalizar, eu adicinei a StatusBar com o tema light, já que o fundo da nossa aplicação é escuro. A propriedade translucent faz com que a barra de status permita que o conteúdo do aplicativo seja exibido abaixo dela.
export function Home(){
return (
<>
<StatusBar barStyle={"light-content"}
backgroundColor='transparent'
translucent
/>
<View style={styles.header}/>
<View style={styles.body}>
<AddNewTodo/>
</View>
</>
)
}
E eis o resultado:
Importando imagens
Eu coloquei 3 opções de tamanhos para que ele se adapte a diferente tamanhos de telas:
Olha só como fica o nosso cabeçalho:
<View style={styles.header}>
<Image source={require('../../assets/logo.png')}></Image>
</View>
Só centralizar tudo o que tem no header e o logo fica centralizado, bonitinho:
header: {
justifyContent: 'center',
alignItems: 'center'
},
Criando o Componente Counter
Como os contadores de tarefas concluídas e criadas são muito semelhantes, podemos criar um único componente e reaproveitar código.
A solução que eu cheguei foi a seguinte:
- Já que estamos utilizando typescript, criei uma interface com as propriedades ‘name’ e ‘value’, já que são as duas únicas coisas que mudam no nosso componente.
interface CounterProps {
name: 'Criadas' | 'Concluídas'
value: number
}
- Criei o componente, fazendo com que, caso seja escolhido a opção ‘Criadas’ ele tenha uma determinada cor, caso contrário ele receberá outra cor.
export function Counter({name, value}: CounterProps){
return (
<View style={styles.container} >
<Text style={
[
name === 'Criadas' ? styles.color1 : styles.color2,
styles.text
]
}>{name}</Text>
<View style={styles.numberContainer}>
<Text style={styles.number}>{value}</Text>
</View>
</View>
)
}
Obs.: Para manter aqui abaixo dos 20000 caractares, vou ocultar os estilos do StyledSheed, mas você pode conferir tudo no link do github que deixarei abaixo.
Na nossa Home, colocamos os componentes abaixo do nosso Input, envolvido por uma View. Por enquanto com os valores aleatório, mas em breve implementaremos essa funcionalidade. Vamos terminar a estilização toda primeiro.
<View style={styles.bodyContent}>
<Counter name="Criadas" value={1}/>
<Counter name="Concluídas" value={4}/>
</View>
Criando o componente Todo
Agora vamos criar o componente de tarefa. Nele precisamos ter um CheckBox. Eu aindei pesquisando e vi que não tem mais essa opção nativa no React Native, então tive que instalar uma biblioteca da comunidade:
npm i react-native-bouncy-checkbox
Instalação concluída, só importar o BouncyCheckbox e usar no nosso componente Todo. O legal é que ele já vem com uma animação bem maneirinha. Só tivemos que definir o tamanho e a cor das bordas. Mais para frente vamos voltar nele para fazer uns ajustes. Além disso, usamos o TouchableOpacity, dentro dele adicionando o ícone de lixo - e eis o nosso botão para excluir uma tarefa.
import { View, Text, TouchableOpacity, Image } from "react-native"
import BouncyCheckbox from "react-native-bouncy-checkbox";
import { styles } from "./style";
interface TodoProps {
name: string
}
export function Todo({name} : TodoProps){
return (
<View style={styles.container}>
<BouncyCheckbox
size={24}
fillColor="#5E60CE"
innerIconStyle={styles.iconStyle}
/>
<Text style={styles.text}>{name}</Text>
<TouchableOpacity>
<Image source={require('@assets/trash.png')}/>
</TouchableOpacity>
</View>
)
}
Agora basta usarmos na nossa aplicação. E para isso eu vou utilizar o FlatList, que é um componente do react-native para lidar com listas. E legal desse componente é que ele renderiza os itens aos poucos na tela, confrome vai surgindo, e não tudo de uma vez. Isso faz com que fique bem performático.
const tasks = ['Integer urna interdum massa libero auctor neque turpis turpis semper.', 'lavar louça']
// Criei uma lista aqui para testarmos por enquanto
<FlatList
data={tasks}
renderItem={(todo) =>
(
<Todo name={todo.item} />
)
}/>
Olha como está ficando! Show de bola, né? Agora só precisamos fazer tudo funcionar! 😀
Implementando as funcionalidades
Antes de começar, vamos fazer alguns ajustes, para concentrar todas as alterações de estados na nossa home para ficar mais fácil. Em nossa interface do Todo, vamos adicionar a propriedade ‘onRemove’ e a propriedade ‘onChecked’. Pois queremos atualizar a tela quando realizamos essas ações.
interface TodoProps {
name: string
onChecked: (cheked: boolean) => void
onRemove: () => void
}
Agora, podemos ir lá na nossa Home e colocar essas propriedades no nosso FlatList.
<FlatList
data={tasks}
renderItem={(todo) =>
(
<Todo
name={todo.item}
onRemove={handleTodoRemove}
onChecked={handleTodoCheck} />
)
}/>
Vamos aproveitar e criar propriedades para o nosso AddNewTodoProps também:
interface AddNewTodoProps {
onChange: (text:string) => void
onPress: () => void
value: string
}
export function AddNewTodo({onPress, onChange, value} : AddNewTodoProps){
return (
<View style={styles.container}>
<TextInput
style={styles.inputField}
placeholder="Adicione uma nova tarefa"
placeholderTextColor="#808080"
onChangeText={onChange}
value={value}
/>
<TouchableOpacity
onPress={onPress}
style={styles.button}>
<View style={styles.iconButton}>
<Text style={styles.textButton}>+</Text>
</View>
</TouchableOpacity>
</View>
)
}
Agora também podemos resgatar o texto na nossa home e lidar com o clique no botão utilizando o useState do React.
Na home eu criei mais uma interface para a Task, para sabermos quando uma tarefa está concluída ou não:
export interface TaskInterface {
name: string,
checked: boolean,
}
Então teremos alguns estados que vão mudar e precisamos usar o useState para refletir essas mudanças na tela:
- Temos as Tasks, que será uma lista de TaskInterface
- Temos a newTask que será uma string, serve para armazenarmos o valor da nova tarefa que vamos adicionar
- Temos por fim o checkedTasks que verifica na nossa lista de tarefas quais que estão concluídas e retorna um número que a gente vai usar no nosso componente Counter.
const [tasks, setTasks] = useState<TaskInterface[]>([])
const [newTask, setNewTask] = useState('')
let checkedTasks = tasks.filter(tasks => tasks.checked === true).length
Inclusive já podemos modificar os nossos Counters:
<Counter name="Criadas" value={tasks.length}/>
<Counter name="Concluídas" value={checkedTasks}/>
Adicionando nova tarefa
Para adicionar uma nova tarefa, podemos fazer o seguinte:
<AddNewTodo onPress={handleTodoAdd} onChange={setNewTask} value={newTask}/>
Na função handleTodoAdd
, procuramos na lista se há alguma tarefa com o mesmo nome, e caso positivo disparamos um alerta dizendo que a tarefa já existe. Isso vai evitar alguns erros.
Caso Não exista essa tarefa, adicionamos essa tarefa na lista com o setTasks e depois limpamos o input com o setNewTask vazio.
function handleTodoAdd(){
const taskWithSameName = tasks.find((task) => task.name === newTask)
if (taskWithSameName) {
Alert.alert('Tarefa já existe', 'Já existe uma tarefa dessa criada')
return
}
setTasks(prevState => [...prevState, {name: newTask, checked: false}])
setNewTask('')
}
Removendo tarefa
Para remover a tarefa, nossa propriedade onRemove no componente Todo fica assim:
onRemove={() => handleTodoRemove(todo.item.name)}
Essa função, utilizamos também o setTasks, pegando os itens anteriores (prevState) e fazendo uma filtragem, excluindo o nome que é igual ao que temos.
function handleTodoRemove(name: string){
setTasks(prevState => prevState.filter((item)=> item.name !== name))
}
Tarefa concluída
Para evitar erros, também adicionamos a propriedade key na lista. Como não teremos tarefas repetidas, podemos usar o próprio nome no campo.
Na propriedade onChecked, enviamos o objeto TaskInterface.
<Todo
key={todo.item.name}
name={todo.item.name}
onRemove={() => handleTodoRemove(todo.item.name)}
onChecked={(isChecked) => {
handleTodoCheck({
name: todo.item.name,
checked: isChecked
})
}}/>
O handleTodoCheck é um map percorrendo a lista e verificando se o nome é igual para fazer a substituição dessa task pela nossa modificada.
function handleTodoCheck(task: TaskInterface){
setTasks(prevState => prevState.map((item) => {
if (item.name === task.name){
return task
} else {
return item
}
}))
}
Agora, dentro do nosso componente Todo, atualizamos para que ele se comporte de maneira diferente, dependendo do seu estado (se a tarefa foi concluída ou não):
export function Todo({name, onRemove, onChecked} : TodoProps){
const [isChecked, setIsChecked] = useState(false)
return (
<View style={[
styles.container,
isChecked && styles.finished
]}>
<BouncyCheckbox
size={24}
fillColor="#5E60CE"
innerIconStyle={isChecked ? styles.iconStyle2 : styles.iconStyle}
onPress={(isChecked) => {
onChecked(isChecked)
setIsChecked(isChecked)
}}
/>
<Text style={isChecked ? styles.text2 : styles.text}>{name}</Text>
<TouchableOpacity onPress={onRemove}>
<Image source={require('@assets/trash.png')}/>
</TouchableOpacity>
</View>
)
}
Caso a tarefa esteja concluída, ele vai adicionar o styles.finished, que nada mais é do que uma opacidade no container:
finished: {
opacity: 0.6
},
Também, se estiver concluída, ele adiciona o styles.text2, que acrescenta aquele risquinho no texto, o tal do ‘line-through’:
text2: {
width: '80%',
color: '#F2F2F2',
textDecorationLine: 'line-through'
},
E pronto, está quase finalizado. Só precisamos ajustar o nosso Input, pois queremos que a borda seja azul quando estiver o foco nele. Então vamos lá.
Mudando o estado do componente quando muda o foco
Esse aqui eu deixei para o final, pois foi um pouco difícil de achar a resposta, mas no fim acabei encontrando. Eu estava me perguntando como mudar a borda quando o TextInput está focado e quando mudar de novo a sua cor quando sair do foco. A solução que cheguei foi a seguinte: são duas propriedades que vamos trabalhar, a onFocus e a onBlur.
O onFocus dispara sempre que estiver focado enquanto que o onBlur dispara sempre que sair do foco. Então, no nosso AddNewTodo, adicionamos mais um hook para saber se está no foco ou não, começando naturalmente com false no seu estado inicial:
const [isFocus, setIsFocus] = useState(false)
return (
<View style={styles.container}>
<TextInput
style={[styles.inputField,
isFocus && styles.focusOutline
]}
placeholder="Adicione uma nova tarefa"
placeholderTextColor="#808080"
onChangeText={onChange}
value={value}
onFocus={() => setIsFocus(true)}
onBlur={()=> setIsFocus(false)}
/>
<TouchableOpacity
onPress={onPress}
style={styles.button}>
<View style={styles.iconButton}>
<Text style={styles.textButton}>+</Text>
</View>
</TouchableOpacity>
</View>
)
Caso esteja focado, o estilo focusOutiline será setado:
focusOutline: {
borderColor: '#5E60CE',
},
E finalizamoooos!!!! Nosso app já pode ser usado!!
Obrigado por acompanhar até aqui! Até a próxima!