Guia Passo a Passo: Implementando Scroll Horizontal Snap no React Native
Christopher Alves
Posted on February 21, 2024
O Foco deste artigo é te auxiliar a criar uma página com componente com scroll horizontal no estilo snap e um header que reflete as alterações entre as etapas.
Essa estrutura é muito útil para se construir wizard forms. Ele é um tipo de formulário muito útil, onde o usuário é guiado a preencher o formulário através de etapas.
Stacks
Neste projeto vamos utilizar as tecnologias:
- Expo - Para a criação e compartilhamento da aplicação, rodando nativamente em Android e iOS.
- React Native Responsive Font Size - Para a padronização do tamanho das fontes em dispositivos Android e iOS.
Estrutura
Como o intuito deste artigo é a construção de um conteúdo com scroll horizontal do tipo snap e com etapas, não vou focar na parte de estilização dos outros componentes.
A estrutura inicial foi dividida em 2 partes:
- Header
- Content Container
Header
Responsável por indicar quais etapas foram concluídas e quais restam finalizar.
Content Container
Responsável em ter o conteúdo dividido em etapas, botões de navegação entre etapas e scroll horizontal snap.
OBS: Para a padronização do tamanho das fontes em diversos dispositivos utilizaremos a lib react-native-fontsize
.
Criei um arquivo utils que tem a lógica para padronizar o tamanho das fontes.
// src/utils/normalize-font.ts
import { Dimensions } from "react-native";
import { RFValue } from "react-native-responsive-fontsize";
export const normalizeFont = (value: number) => {
const { height } = Dimensions.get("window");
const valueNormalized = RFValue(value, height);
return valueNormalized;
};
Estrutura inicial
// src/app/index.tsx
import { View, Text } from "react-native";
import { normalizeFont } from "../utils/normalize-font";
export default function Home() {
return (
// Screen Container
<View
style={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
>
{/* Sign Up Container */}
<View
style={{
width: "100%",
borderRadius: 2,
backgroundColor: "2d0381",
}}
>
{/* Header */}
<View
style={{
backgroundColor: "#f0000099",
alignItems: "center",
justifyContent: "center",
padding: 40,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Header
</Text>
</View>
{/* Content Container */}
<View
style={{
backgroundColor: "green",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
width: "100%",
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Content
</Text>
</View>
</View>
</View>
);
}
Construindo o Content Container
Vamos deixar o Header por último já que seu comportamento depende da ação do usuário no content container.
Próximas etapas são:
- Alterar o View do Content Container para ScrollView, pois precisamos do scroll horizontal e adicionar o conteúdo dentro do Content Container.
- Ajustar width das etapas dentro do Content Container.
- Habilitar paging enabled.
- Navegação entre as etapas.
Alterando View do Content Container para ScrollView
// src/app/index.tsx
import { View, Text, ScrollView } from "react-native";
import { normalizeFont } from "../utils/normalize-font";
export default function Home() {
return (
// Screen Container
<View
style={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
>
{/* Sign Up Container */}
<View
style={{
width: "100%",
borderRadius: 2,
backgroundColor: "2d0381",
}}
>
{/* Header */}
<View
style={{
backgroundColor: "#f0000099",
alignItems: "center",
justifyContent: "center",
padding: 40,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Header
</Text>
</View>
{/* Content Container */}
<ScrollView
style={{
flexGrow: 0,
alignContent: "center",
}}
contentContainerStyle={{
flexGrow: 1,
position: "relative",
}}
horizontal
>
<View
style={{
width: "100%",
backgroundColor: "gray",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Welcome (Step 1)
</Text>
</View>
<View
style={{
width: "100%",
backgroundColor: "green",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Account Information (Step 2)
</Text>
</View>
<View
style={{
width: "100%",
backgroundColor: "orange",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Personal Information (Step 3)
</Text>
</View>
<View
style={{
width: "100%",
backgroundColor: "red",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Complete (Step 4)
</Text>
</View>
</ScrollView>
</View>
</View>
);
}
Com a estrutura acima ainda não é possível navegar horizontalmente pois todas as etapas estão com width = 100%, pegando todo o espaço disponível, atrapalhando o scroll. Para que o scroll funcione precisamos deixar o width das etapas igual ao espaço disponível do content container, mas sem utilizar o width 100%, exemplo:
Se o content container tem width = 370px, então todas as etapas dentro dele precisam também ter esse width.
Ajustando o width das etapas dentro do content container
Para que as etapas do content container tenham um width dinâmico vamos atribuir ao width de cada etapa, o tamanho da tela do dispositivo, menos qualquer gap, padding ou margin horizontal que exista.
Para pegar o width do dispositivo vamos utilizar o Dimensions
, método que vem do react-native.
Vamos também atribuir a variável screenPadding
o padding da tela, que é de 20.
Na variável stepFormWidth
atribuímos o resultado do cálculo do width da tela menos padding da tela. Como o padding da tela é de 20 para o lado esquerdo e 20 para o lado direito, podemos multiplicar a variável screenPadding
por 2
import { Dimensions } from "react-native";
const { width } = Dimensions.get("window");
const screenPadding = 20;
const stepFormWidth = width - screenPadding * 2;
Ficando no arquivo assim:
// src/app/index.tsx
import { View, Text, Dimensions, ScrollView } from "react-native";
import { normalizeFont } from "../utils/normalize-font";
const { width } = Dimensions.get("window");
export default function Home() {
const screenPadding = 20;
const stepFormWidth = width - screenPadding * 2;
return (
// Screen Container
<View
style={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
>
{/* Sign Up Container */}
<View
style={{
width: "100%",
borderRadius: 2,
backgroundColor: "2d0381",
}}
>
{/* Header */}
<View
style={{
backgroundColor: "#f0000099",
alignItems: "center",
justifyContent: "center",
padding: 40,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Header
</Text>
</View>
{/* Content Container */}
<ScrollView
style={{
flexGrow: 0,
alignContent: "center",
}}
contentContainerStyle={{
flexGrow: 1,
position: "relative",
}}
horizontal
>
<View
style={{
width: stepFormWidth,
backgroundColor: "gray",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Welcome (Step 1)
</Text>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "green",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Account Information (Step 2)
</Text>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "orange",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Personal Information (Step 3)
</Text>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "red",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Complete (Step 4)
</Text>
</View>
</ScrollView>
</View>
</View>
);
}
Agora que os Steps já possuem um tamanho específico, conseguimos fazer o scroll horizontal.
Habilitando Paging Enabled
A maneira que o scroll horizontal se comporta não é a mais ideal para se utilizar em um componente do tipo wizard form, por isso faremos com que o scroll tenha um comportamento no estilo snap, no qual ao "scrollar" só um pouco, ele já irá para o centro da próxima etapa.
O ScrollView tem a propriedade pagingEnabled
que permite habilitar este comportamento no estilo Snap.
// ...
<ScrollView
// ...
pagingEnabled
>
// ...
Navegação entre as etapas.
Adicionando os botões para navegação entre as etapas.
Agora dentro de cada etapa, adicionaremos os botões que permite prosseguir ou retornar entre as etapas.
// src/app/index.tsx
import { View, Text, Dimensions, Button, ScrollView } from "react-native";
import { normalizeFont } from "../utils/normalize-font";
const { width } = Dimensions.get("window");
export default function Home() {
const screenPadding = 20;
const stepFormWidth = width - screenPadding * 2;
return (
// Screen Container
<View
style={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
>
{/* Sign Up Container */}
<View
style={{
width: "100%",
borderRadius: 2,
backgroundColor: "2d0381",
}}
>
{/* Header */}
<View
style={{
backgroundColor: "#f0000099",
alignItems: "center",
justifyContent: "center",
padding: 40,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Header
</Text>
</View>
{/* Content Container */}
<ScrollView
style={{
flexGrow: 0,
alignContent: "center",
}}
contentContainerStyle={{
flexGrow: 1,
position: "relative",
}}
horizontal
pagingEnabled
>
<View
style={{
width: stepFormWidth,
backgroundColor: "gray",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Welcome (Step 1)
</Text>
<Button
title="continue"
color="#000"
onPress={() => {
console.log("go to next step");
}}
/>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "green",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Button
title="previous step"
color="#000"
onPress={() => {
console.log("go to previous step");
}}
/>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Account Information (Step 2)
</Text>
<Button
title="next step"
color="#000"
onPress={() => {
console.log("go to next step");
}}
/>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "orange",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Button
title="previous step"
color="#000"
onPress={() => {
console.log("go to previous step");
}}
/>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Personal Information (Step 3)
</Text>
<Button
title="next step"
color="#000"
onPress={() => {
console.log("go to next step");
}}
/>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "red",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Complete (Step 4)
</Text>
</View>
</ScrollView>
</View>
</View>
);
}
- Welcome (Step 1) só tem a possibilidade de prosseguir para próxima etapa.
- Account Information (Step 2) tem a possibilidade de prosseguir e retornar.
- Personal Information (Step 3) tem a possibilidade de prosseguir e retornar.
- Complete (Step 4) será a tela de finalização, então não tem como prosseguir e nem retornar.
Lógica para navegação entre as etapas.
Criando a Ref
Para ter a navegação entre os steps, precisamos ter a referência do ScrollView e através dela utilizar o método scrollTo, onde nos permite fazer o scroll até um ponto X ou Y.
import { useRef } from "react";
const scrollRef = useRef<ScrollView>(null);
// ...
<ScrollView
ref={scrollRef}
// ...
>
// ...
Com a referência do componente scrollview, conseguimos utilizar o scrollTo para navegar horizontalmente para o lado esquerdo ou direito.
Navegar para próxima etapa
Para navegar para a próxima etapa, precisamos dizer para nosso componente scrollview navegar pelo eixo X até um valor específico e como nossos steps possuem um tamanho igual ao do dispositivo, diminuindo apenas espaçamento de padding, conseguimos fazer uso dessa informação para navegar.
Começamos criando um método para que sejam utilizados nos botões para prosseguir.
Vamos chamá-la de handlePressNextStep
que recebe como parâmetro o próximo passo que deseja ir (nextStep
).
Dentro dele criamos um objeto que será responsável por ter como chave o nome das etapas e como valor o ponto no eixo X que esse componente está presente. Como o pagingEnabled
está ativado no scrollView, assim que scrollar para dentro do próximo step, o scrollview irá centralizar o scroll para dentro deste step.
Como os componentes possuem o width do mesmo tamanho da tela do dispositivo, podemos dizer que o segundo step está presente no eixo X no valor de width da tela - padding horizontal e o step 3 está presente no valor do step 2 * 2.
Etapas:
1ª - Welcome (Step 1) - inicia em x: 0.
2ª - Account Information (Step 2) - inicia em x: (width da tela - padding horizontal).
3ª - Personal Information (Step 3) - inicia em x: (width da tela - padding horizontal) * 2.
4ª - Complete (Step 4) - inicia em x: (width da tela - padding horizontal) * 3
exemplo: Meu dispositivo tem width 390px, e adicionei 20px de padding horizontal, logo o tamanho dos meus steps serão de 370px:
valor dos steps: 390px - 20px = 370x
Então Welcome (Step 1) se encontra no eixo X: 0.
Account Information (Step 2) se encontra no eixo X: 370px.
Personal Information (Step 3) se encontra no eixo X: 370px * 2 = 740px.
Complete (Step 4) se encontra no eixo X: 370px * 3 = 1110px.
Com essas informações montamos o método abaixo:
const handlePressNextStep = (nextStep: string) => {
const formSteps = {
account: stepFormWidth,
personal: stepFormWidth * 2,
complete: stepFormWidth * 3,
};
scrollRef?.current?.scrollTo({
x: formSteps[nextStep as keyof typeof formSteps],
});
};
Como já iniciamos no step Welcome podemos deixá-la de fora.
Navegar para etapa anterior
Assim como na navegação para próxima etapa, criamos um método para fazer scroll para etapa anterior, vamos chamá-la de handlePressBackButton
e que recebe como parâmetro a etapa anterior que deseja ir (previousStep
).
const handlePressBackButton = (previousStep: string) => {
const formSteps = {
welcome: 0,
account: stepFormWidth,
};
scrollRef?.current?.scrollTo({
x: formSteps[previousStep as keyof typeof formSteps],
});
};
Utilizando a navegação
// src/app/index.tsx
import { useRef } from "react";
import { View, Text, Dimensions, Button, ScrollView } from "react-native";
import { normalizeFont } from "../utils/normalize-font";
const { width } = Dimensions.get("window");
export default function Home() {
const scrollRef = useRef<ScrollView>(null);
const screenPadding = 20;
const stepFormWidth = width - screenPadding * 2;
const handlePressNextStep = (nextStep: string) => {
const formSteps = {
account: stepFormWidth,
personal: stepFormWidth * 2,
complete: stepFormWidth * 3,
};
scrollRef?.current?.scrollTo({
x: formSteps[nextStep as keyof typeof formSteps],
});
};
const handlePressBackButton = (previousStep: string) => {
const formSteps = {
welcome: 0,
account: stepFormWidth,
};
scrollRef?.current?.scrollTo({
x: formSteps[previousStep as keyof typeof formSteps],
});
};
return (
// Screen Container
<View
style={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
>
{/* Sign Up Container */}
<View
style={{
width: "100%",
borderRadius: 2,
backgroundColor: "2d0381",
}}
>
{/* Header */}
<View
style={{
backgroundColor: "#f0000099",
alignItems: "center",
justifyContent: "center",
padding: 40,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Header
</Text>
</View>
{/* Content Container */}
<ScrollView
style={{
flexGrow: 0,
alignContent: "center",
}}
contentContainerStyle={{
flexGrow: 1,
position: "relative",
}}
horizontal
pagingEnabled
ref={scrollRef}
>
<View
style={{
width: stepFormWidth,
backgroundColor: "gray",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Welcome (Step 1)
</Text>
<Button
title="continue"
color="#000"
onPress={() => {
handlePressNextStep("account");
}}
/>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "green",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Button
title="previous step"
color="#000"
onPress={() => {
handlePressBackButton("welcome");
}}
/>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Account Information (Step 2)
</Text>
<Button
title="next step"
color="#000"
onPress={() => {
handlePressNextStep("personal");
}}
/>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "orange",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Button
title="previous step"
color="#000"
onPress={() => {
handlePressBackButton("account");
}}
/>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Personal Information (Step 3)
</Text>
<Button
title="next step"
color="#000"
onPress={() => {
handlePressNextStep("complete");
}}
/>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "red",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Complete (Step 4)
</Text>
</View>
</ScrollView>
</View>
</View>
);
}
Desativando o scroll
Agora que já está funcionando o seu scroll através do pressionar dos botões, é necessário desativar o scroll para que o usuário consiga navegar apenas pelos botões.
Para isso é muito simples, o ScrollView tem a propriedade scrollEnabled
que é responsável por habilitar ou não o scroll dele. Então atribuímos o seu valor para false scrollEnabled={false}
e assim não será mais possível scrollar, apenas navegar pelos botões.
Construindo o Header
Agora que temos a parte do content container com as etapas e navegação entre eles através dos botões, vamos criar o Header.
No Header teremos algumas labels indicando qual etapa que o usuário está e quantas faltam para finalizar o preenchimento do formulário.
// src/app/index.tsx
import { Dimensions, Text, View } from "react-native";
import { normalizeFont } from "../utils/normalize-font";
export default function Home() {
// ...
return (
// Screen Container
<View
style={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
>
{/* Sign Up Container */}
<View
style={{
width: "100%",
borderRadius: 2,
backgroundColor: "#2d0381",
}}
>
{/* Header */}
<View
style={{
gap: 20,
padding: 20,
}}
>
<Text
style={{
fontSize: normalizeFont(20),
color: "#FAF9F6",
textTransform: "capitalize",
}}
>
Create account steps
</Text>
{/* Steps Indicator Container */}
<View
style={{
position: "relative",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
{/* StepIndicator Line */}
<View
style={{
position: "absolute",
height: 1,
width: "100%",
backgroundColor: "#FAF9F6",
}}
/>
{/* StepIndicator Label Wrapper - Welcome */}
<View
style={{
maxWidth: 100,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: "#9ca3af",
}}
>
<Text
style={{
fontSize: normalizeFont(12),
color: "#FAF9F6",
fontWeight: "bold",
}}
>
Welcome Step
</Text>
</View>
{/* StepIndicator Label Wrapper - Account Information */}
<View
style={{
maxWidth: 100,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: "#9ca3af",
}}
>
<Text
style={{
fontSize: normalizeFont(12),
color: "#FAF9F6",
fontWeight: "bold",
}}
>
Account Information
</Text>
</View>
{/* StepIndicator Label Wrapper - Personal Information */}
<View
style={{
maxWidth: 100,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: "#9ca3af",
}}
>
<Text
style={{
fontSize: normalizeFont(12),
color: "#FAF9F6",
fontWeight: "bold",
}}
>
Personal Information
</Text>
</View>
{/* Divisor */}
<View
style={{
position: "absolute",
bottom: -20,
height: 1,
width: "100%",
backgroundColor: "#9ca3af",
}}
/>
</View>
</View>
{/* Content Container */}
{/* ... */}
</View>
</View>
);
}
Lógica para indicar se a etapa foi finalizada
Com as labels já presentes no header, conseguimos implementar uma lógica que altere o estilo da label, caso a etapa tenha sido finalizada.
E como saberemos se ela foi finalizada?
No content container, ao navegar para a próxima etapa vamos considerar que ela foi finalizada e será refletido uma mudança no estilo da label correspondente a ela, caso ela retorna para a etapa anterior, ela deve voltar a ser incompleta.
Para isso, vamos criar um estado que será responsável por armazenar a lista de etapas concluídas:
const [stepsCompleted, setStepsCompleted] = useState<string[]>([]);
E então nas funções handlePressNextStep
e handlePressBackButton
fazemos a lógica para adicionar/remover a etapa concluída da lista de etapas.
A função handlePressNextStep
receberá mais um parâmetro, que será o currentStep
e para ficar melhor ordenado os parâmetros, vamos colocá-lo como o primeiro parâmetro da função, ficando assim:
const handlePressNextStep = (currentStep: string, nextStep: string) => {
setStepsCompleted((prevState) =>
prevState.includes(currentStep) ? prevState : [...prevState, currentStep],
);
const formSteps = {
account: stepFormWidth,
personal: stepFormWidth * 2,
complete: stepFormWidth * 3,
};
scrollRef?.current?.scrollTo({
x: formSteps[nextStep as keyof typeof formSteps],
});
};
É necessário antes de alterar o estado, verificar se a etapa atual já está na lista, para que não seja adicionado dado duplicado na lista.
Agora vamos adicionar o currentStep
como primeiro parâmetro também na função handlePressBackButton
:
const handlePressBackButton = (currentStep: string, previousStep: string) => {
setStepsCompleted((prevState) =>
// Validação adicionada para que retorne somente as etapas que sejam diferentes da atual e da anterior, deixando na lista apenas as etapas que já foram avançadas.
prevState.filter((step) => step !== currentStep && step !== previousStep),
);
const formSteps = {
welcome: 0,
account: stepFormWidth,
};
scrollRef?.current?.scrollTo({
x: formSteps[previousStep as keyof typeof formSteps],
});
};
Os botões de ação no content container ficaram assim:
<ScrollView>
<View
//...
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Welcome (Step 1)
</Text>
<Button
title="continue"
color="#000"
onPress={() => {
handlePressNextStep("welcome", "account");
}}
/>
</View>
<View
//...
>
<Button
title="previous step"
color="#000"
onPress={() => {
handlePressBackButton("account", "welcome");
}}
/>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Account Information (Step 2)
</Text>
<Button
title="next step"
color="#000"
onPress={() => {
handlePressNextStep("account", "personal");
}}
/>
</View>
<View
//...
>
<Button
title="previous step"
color="#000"
onPress={() => {
handlePressBackButton("personal", "account");
}}
/>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Personal Information (Step 3)
</Text>
<Button
title="next step"
color="#000"
onPress={() => {
handlePressNextStep("personal", "complete");
}}
/>
</View>
<View
//...
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Complete (Step 4)
</Text>
</View>
</ScrollView>
Agora ao navegar para a próxima etapa, adicionamos a etapa atual na lista e ao retornar uma etapa removemos ela da lista, exemplo:
Etamos na etapa de Account Information, logo a lista de etapas será ['welcome']
.
Passei para a etapa Personal Information, a lista será ['welcome', 'account']
.
Retornei para etapa Account Information, a lista retorna para ['welcome']
.
Alterando estilo da label ao concluir a etapa
Agora que já temos um estado com a lista de etapas concluídas, vamos utilizá-la para saber se a etapa "X" está presente na lista, se estiver, o background-color do StepIndicator Label Wrapper
será "#22c55e"
, caso contrário será "#9ca3af"
.
// src/app/index.tsx
import { useState } from "react";
//...
export default function Home() {
const [stepsCompleted, setStepsCompleted] = useState<string[]>([]);
// ...
return (
// Screen Container
<View
style={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
>
{/* Sign Up Container */}
<View
style={{
width: "100%",
borderRadius: 2,
backgroundColor: "2d0381",
}}
>
{/* Header */}
<View
style={{
gap: 20,
padding: 20,
}}
>
<Text
style={{
fontSize: normalizeFont(20),
color: "#FAF9F6",
textTransform: "capitalize",
}}
>
Create account steps
</Text>
{/* Steps Indicator Container */}
<View
style={{
position: "relative",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
{/* StepIndicator Line */}
<View
style={{
position: "absolute",
height: 1,
width: "100%",
backgroundColor: "#FAF9F6",
}}
/>
{/* StepIndicator Label Wrapper - Welcome */}
<View
style={{
maxWidth: 100,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: stepsCompleted.includes("welcome")
? "#22c55e"
: "#9ca3af",
}}
>
<Text
style={{
fontSize: normalizeFont(12),
color: "#FAF9F6",
fontWeight: "bold",
}}
>
Welcome Step
</Text>
</View>
{/* StepIndicator Label Wrapper - Account Information */}
<View
style={{
maxWidth: 100,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: stepsCompleted.includes("account")
? "#22c55e"
: "#9ca3af",
}}
>
<Text
style={{
fontSize: normalizeFont(12),
color: "#FAF9F6",
fontWeight: "bold",
}}
>
Account Information
</Text>
</View>
{/* StepIndicator Label Wrapper - Personal Information */}
<View
style={{
maxWidth: 100,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: stepsCompleted.includes("personal")
? "#22c55e"
: "#9ca3af",
}}
>
<Text
style={{
fontSize: normalizeFont(12),
color: "#FAF9F6",
fontWeight: "bold",
}}
>
Personal Information
</Text>
</View>
{/* Divisor */}
<View
style={{
position: "absolute",
bottom: -20,
height: 1,
width: "100%",
backgroundColor: "#9ca3af",
}}
/>
</View>
</View>
{/* Content Container */}
{/* ... */}
</View>
</View>
);
}
Código finalizado
E assim temos o codigo finalizado com todas as lógicas.
// src/app/index.tsx
import { useRef, useState } from "react";
import { Button, Dimensions, ScrollView, Text, View } from "react-native";
import { normalizeFont } from "../utils/normalize-font";
const { width } = Dimensions.get("window");
export default function Home() {
const [stepsCompleted, setStepsCompleted] = useState<string[]>([]);
const scrollRef = useRef<ScrollView>(null);
const screenPadding = 20;
const stepFormWidth = width - screenPadding * 2;
const handlePressNextStep = (currentStep: string, nextStep: string) => {
setStepsCompleted((prevState) =>
prevState.includes(currentStep) ? prevState : [...prevState, currentStep],
);
const formSteps = {
account: stepFormWidth,
personal: stepFormWidth * 2,
complete: stepFormWidth * 3,
};
scrollRef?.current?.scrollTo({
x: formSteps[nextStep as keyof typeof formSteps],
});
};
const handlePressBackButton = (currentStep: string, previousStep: string) => {
setStepsCompleted((prevState) =>
prevState.filter((step) => step !== currentStep && step !== previousStep),
);
const formSteps = {
welcome: 0,
account: stepFormWidth,
};
scrollRef?.current?.scrollTo({
x: formSteps[previousStep as keyof typeof formSteps],
});
};
return (
// Screen Container
<View
style={{
flexGrow: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
}}
>
{/* Sign Up Container */}
<View
style={{
width: "100%",
borderRadius: 2,
backgroundColor: "#2d0381",
}}
>
{/* Header */}
<View
style={{
gap: 20,
padding: 20,
}}
>
<Text
style={{
fontSize: normalizeFont(20),
color: "#FAF9F6",
textTransform: "capitalize",
}}
>
Create account steps
</Text>
{/* Steps Indicator Container */}
<View
style={{
position: "relative",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
{/* StepIndicator Line */}
<View
style={{
position: "absolute",
height: 1,
width: "100%",
backgroundColor: "#FAF9F6",
}}
/>
{/* StepIndicator Label Wrapper - Welcome */}
<View
style={{
maxWidth: 100,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: stepsCompleted.includes("welcome")
? "#22c55e"
: "#9ca3af",
}}
>
<Text
style={{
fontSize: normalizeFont(12),
color: "#FAF9F6",
fontWeight: "bold",
}}
>
Welcome Step
</Text>
</View>
{/* StepIndicator Label Wrapper - Account Information */}
<View
style={{
maxWidth: 100,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: stepsCompleted.includes("account")
? "#22c55e"
: "#9ca3af",
}}
>
<Text
style={{
fontSize: normalizeFont(12),
color: "#FAF9F6",
fontWeight: "bold",
}}
>
Account Information
</Text>
</View>
{/* StepIndicator Label Wrapper - Personal Information */}
<View
style={{
maxWidth: 100,
alignItems: "center",
justifyContent: "center",
borderRadius: 8,
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: stepsCompleted.includes("personal")
? "#22c55e"
: "#9ca3af",
}}
>
<Text
style={{
fontSize: normalizeFont(12),
color: "#FAF9F6",
fontWeight: "bold",
}}
>
Personal Information
</Text>
</View>
{/* Divisor */}
<View
style={{
position: "absolute",
bottom: -20,
height: 1,
width: "100%",
backgroundColor: "#9ca3af",
}}
/>
</View>
</View>
{/* Content Container */}
<ScrollView
style={{
flexGrow: 0,
alignContent: "center",
}}
contentContainerStyle={{
flexGrow: 1,
position: "relative",
}}
horizontal
pagingEnabled
scrollEnabled={false}
ref={scrollRef}
>
<View
style={{
width: stepFormWidth,
backgroundColor: "gray",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Welcome (Step 1)
</Text>
<Button
title="continue"
color="#000"
onPress={() => {
handlePressNextStep("welcome", "account");
}}
/>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "green",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Button
title="previous step"
color="#000"
onPress={() => {
handlePressBackButton("account", "welcome");
}}
/>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Account Information (Step 2)
</Text>
<Button
title="next step"
color="#000"
onPress={() => {
handlePressNextStep("account", "personal");
}}
/>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "orange",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Button
title="previous step"
color="#000"
onPress={() => {
handlePressBackButton("personal", "account");
}}
/>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Personal Information (Step 3)
</Text>
<Button
title="next step"
color="#000"
onPress={() => {
handlePressNextStep("personal", "complete");
}}
/>
</View>
<View
style={{
width: stepFormWidth,
backgroundColor: "red",
alignItems: "center",
justifyContent: "center",
paddingVertical: 160,
}}
>
<Text style={{ color: "white", fontSize: normalizeFont(30) }}>
Complete (Step 4)
</Text>
</View>
</ScrollView>
</View>
</View>
);
}
Há diversas melhorias para implementar neste projetinho, mas como o foco deste artigo é a construção de um scroll horizontal tipo snap e com etapas, deixamos as melhorias e refatorações para outro artigo.
Caso tenha interesse, pode acompanhar as melhorias do projeto através do repositório clicando aqui
Posted on February 21, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 21, 2024