Guia Passo a Passo: Implementando Scroll Horizontal Snap no React Native

chriscode

Christopher Alves

Posted on February 21, 2024

Guia Passo a Passo: Implementando Scroll Horizontal Snap no React Native

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
  >
// ...
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • 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}
  // ...
>
// ...
Enter fullscreen mode Exit fullscreen mode

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],
  });
};
Enter fullscreen mode Exit fullscreen mode

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],
  });
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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[]>([]);
Enter fullscreen mode Exit fullscreen mode

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],
  });
};
Enter fullscreen mode Exit fullscreen mode

É 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],
  });
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
chriscode
Christopher Alves

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