Upload de Imagens e uso da Câmera no React Native

Esse post é a sexta parte da série de posts “Clone AirBnB com AdonisJS, React Native e ReactJS” onde iremos construir do zero uma aplicação web com ReactJS e também uma aplicação mobile com React Native com dados servidos através de uma API REST feita com NodeJS utilizando o framework AdonisJS.

Nesse ponto já temos a aplicação criada e configurada para se comunicar com a API desenvolvida no começo dessa série, e com isso já conseguimos implementar o processo de autenticação e cadastro de usuário, nessa parte iremos instalar e configurar a Câmera para podermos além de selecionar as imagens do dispositivo, tirar fotos para o cadastro do imóvel, e por fim enviar essas imagens para serem cadastradas no banco de dados da API.

Iniciando a Aplicação

Caso você tenha entrado direto nessa parte, você pode acessar esse link e baixar o projeto com todas as configurações do post anterior, apesar de eu recomendar pessoalmente que você leia as duas partes anteriores para entender o fluxo da aplicação.

Ou se você já viu as partes anteriores pode continuar para a próxima sessão onde iremos começar a instalação da lib de Câmera.

Instalando e Configurando o react-native-camera

Para não deixar o post muito extenso explicando o passo a passo da instalação e configuração do react-native-camera, você pode correr no Blog da Rocketseat e ler esse post sobre a instalação e configuração da Câmera.

Caso você tenha algum problema na instalação ou não tenha conseguido fazer todas as configurações acesse esse link e baixe a estrutura configurada para dar continuidade.

Criando Primeira Tela do Cadastro de Imóveis

Para começar o cadastro a primeira coisa que vamos fazer é criar um botão sobre o mapa para que quando clicado comece o processo de cadastro do imóvel, e como nesse post criaremos vários novos estilos, vamos fazer uma pequena mudança no arquivo de estilos para que fique mais organizado, primeiramente retire a instrução export do final do arquivo src/pages/main/styles.js em seguida, adicione um export antes de todas as const’s, e feito isso vamos criar os seguintes componentes:

export const NewButtonContainer = styled.TouchableHighlight`
  position: absolute;
  bottom: 54;
  left: 20;
  right: 20;
  alignSelf: center;
  borderRadius: 5px;
  paddingVertical: 20px;
  backgroundColor: #fc6663;
`;

export const ButtonsWrapper = styled.View`
  position: absolute;
  bottom: 54;
  left: 20;
  right: 20;
`;

export const CancelButtonContainer = styled.TouchableHighlight`
  alignSelf: stretch;
  borderRadius: 5px;
  paddingVertical: 20px;
  backgroundColor: #555;
  marginTop: 20px;
`;

export const SelectButtonContainer = styled.TouchableHighlight`
  alignSelf: stretch;
  borderRadius: 5px;
  paddingVertical: 20px;
  backgroundColor: #fc6663;
`;

export const ButtonText = styled.Text`
  color: #fff;
  fontSize: 16px;
  textAlign: center;
  fontWeight: bold;
`;

Obs.: Não se esqueça de importá-los no src/pages/main/index.js.

Em seguida vamos deixar o state preparado para toda a parte de cadastro de um novo imóvel, seu código final vai ficar:

state = {
    locations: [],
    newRealty: false,
    cameraModalOpened: false,
    dataModalOpened: false,
    realtyData: {
      location: {
        latitude: null,
        longitude: null,
      },
      name: '',
      price: '',
      address: '',
      images: [],
    },
};

Temos uma variável de controle para os botões de Novo Imóvel e Selecionar Localização, 2 variáveis de controle para os modais que implementaremos logo na sequência (cameraModalOpened e dataModalOpened) e um objeto que irá guardar todas as informações referentes ao imóvel (realtyData).

A primeira coisa que devemos fazer no processo de cadastro de um imóvel é selecionar qual a localização do mesmo, e para isso iremos reaproveitar o botão de Novo Imóvel para fazer uma renderização condicional, quando a variável newRealty do State for false será mostrado o botão Novo Imóvel, quando ela for true serão mostrados os botões para Selecionar Localização e Cancelar, como na imagem abaixo:

Para que os botões funcionem corretamente vamos criar uma função chamada renderConditionalsButtons entre o componentDidMount e o método render() e seu conteúdo será o seguinte:

renderConditionalsButtons = () => (
    !this.state.newRealty ? (
      <NewButtonContainer onPress={this.handleNewRealtyPress}>
        <ButtonText>Novo Imóvel</ButtonText>
      </NewButtonContainer>
    ) : (
      <ButtonsWrapper>
        <SelectButtonContainer onPress={this.handleGetPositionPress}>
          <ButtonText>Selecionar localização</ButtonText>
        </SelectButtonContainer>
        <CancelButtonContainer onPress={this.handleNewRealtyPress}>
          <ButtonText>Cancelar</ButtonText>
        </CancelButtonContainer>
      </ButtonsWrapper>
    )
  )

Nessa parte usamos todos os componentes criados para os botões, mas ainda faltou criarmos as funções de callback dos botões e também chamar essa função no método render(), segue abaixo o escopo dessas 2 funções que devem ser criadas logo abaixo da função renderConditionalsButtons:

handleNewRealtyPress = () => 
    this.setState({ newRealty: !this.state.newRealty })

handleGetPositionPress = async () => {
  try {
    const [longitude, latitude] = await this.map.getCenter();
    this.setState({
      cameraModalOpened: true,
      realtyData: {
        ...this.state.realtyData,
        location: {
          latitude,
          longitude,
        },
      },
    });
  } catch (err) {
    console.tron.log(err);
  }
}

A função handleNewRealtyPress apenas muda o valor da variável newRealty de true para false e vice-versa, já a função handleGetPositionPress utiliza um método disponibilizado pelo Mapbox que permite obter o ponto central do mapa, em seguida usamos esse ponto para adicionar as coordenadas selecionadas no objeto locationdentro do objeto realtyData, mas para que essa função funcione corretamente temos que adicionar a propriedade ref no componente de mapa, para isso basta adicionar as linhas abaixo no MapboxGL.MapView:

ref={map => {
  this.map = map;
}}

E por último vamos dentro do método render, dentro do Container, logo após o fechamento do componente MapboxGL.MapView e adicionar a seguinte instrução: { this.renderConditionalsButtons() }

Agora quando você rodar a aplicação os botões devem estar funcionando corretamente, mas ainda falta um detalhe que pode atrapalhar na experiência do usuário, no momento de selecionar a localização não tem em lugar algum apontando onde fica exatamente o meio, por isso iremos criar um Marker que irá indicar exatamente onde é o meio a ser selecionado, para isso acesse esse link para obter a imagem do marker, que está dentro da pasta src/images.

Antes mesmo de criarmos a função para inserir o Marker no mapa vamos novamente para o src/pages/main/styles.js e criar mais um componente, ficando assim:

export const Marker = styled.Image`
  width: 60px;
  height: 60px;
  position: absolute;
  alignSelf: center;
  top: ${(Dimensions.get('window').height / 2) - 60};
`;

E para que não haja erro você deve também adicionar a importação para o Dimensions no começo desse arquivo, ficando assim:

import { Dimensions } from 'react-native';

A função acima irá criar um componente que irá sobrepor o mapa e sua parte inferior, ou seja, a ponta do Marker será posicionada exatamente no meio da tela, agora adicionando esse componente no export dos estilos e adicionando no import desses componentes na src/pages/main/index.jspodemos criar a função renderMarker, que deve ficar assim:

renderMarker = () => (
  this.state.newRealty &&
  !this.state.cameraModalOpened &&
  <Marker resizeMode="contain" source={require('../../images/marker.png')} />
)

É uma função bem simples que faz uma renderização condicional baseada no valor de 2 variáveis do state, a newRealty e cameraModalOpened, ou seja, esse Marker vai aparecer apenas quando o newRealty for true e o cameraModalOpened for false.

Para que funcione corretamente agora temos que adicionar a chamada dessa função no método render() do componente, logo após a chamada da função this.renderConditionalsButtons(), ficando assim:

{ this.renderConditionalsButtons() }
{ this.renderMarler() }

E assim terminamos a primeira parte do cadastro, onde já é possível selecionar a opção de Cadastrar um novo imóvel e selecionar a localização do mesmo no mapa, retornando as coordenadas para o state.

Agora bora para a próxima parte? (aleluia \o/)

Criação do Modal para Captura de Imagens do Imóvel

Uma vez seleciona a localização do imóvel vamos para a próxima etapa, que é permitir que o usuário tire fotos do imóvel, para posteriormente cadastrá-las junto com os demais dados, e para isso a primeira coisa que vamos fazer é criar o estilo dos componentes, como a lista para o Modal ficou um pouco extensa, deixei nesse link as constantes que vemos adicionar no src/pages/main/styles.js.

No src/pages/main/index.js o import deve ficar assim:

import {
  Container,
  AnnotationContainer,
  AnnotationText,
  NewButtonContainer,
  ButtonsWrapper,
  CancelButtonContainer,
  SelectButtonContainer,
  ButtonText,
  Marker,
  ModalContainer,
  ModalImagesListContainer,
  ModalImagesList,
  ModalImageItem,
  ModalButtons,
  CameraButtonContainer,
  CancelButtonText,
  ContinueButtonText,
  TakePictureButtonContainer,
  TakePictureButtonLabel,
} from './styles';

Agora vamos à criação do Modal, e para que não haja erros, primeiro vamos adicionar algumas importações no início do arquivo index.js, sendo elas:

import { StatusBar, Modal } from 'react-native'; // Adicionado o Modal
import { RNCamera } from 'react-native-camera';

Feito isso vamos criar a função responsável por renderizar o componente Modal na página, sobre o mapa,  a função criada vai se chamar renderCameraModal, ela pode ser adicionada logo antes do método render() e seu escopo ficará assim:

renderCameraModal = () => (
    <Modal
      visible={this.state.cameraModalOpened}
      transparent={false}
      animationType="slide"
      onRequestClose={this.handleCameraModalClose}
    >
      <ModalContainer>
        <ModalContainer>
          <RNCamera
            ref={camera => {
              this.camera = camera;
            }}
            style={{ flex: 1 }}
            type={RNCamera.Constants.Type.back}
            autoFocus={RNCamera.Constants.AutoFocus.on}
            flashMode={RNCamera.Constants.FlashMode.off}
            permissionDialogTitle={"Permission to use camera"}
            permissionDialogMessage={
              "We need your permission to use your camera phone"
            }
          />
          <TakePictureButtonContainer onPress={this.handleTakePicture}>
            <TakePictureButtonLabel />
          </TakePictureButtonContainer>
        </ModalContainer>
        { this.renderImagesList() }
        <ModalButtons>
          <CameraButtonContainer onPress={this.handleCameraModalClose}>
            <CancelButtonText>Cancelar</CancelButtonText>
          </CameraButtonContainer>
          <CameraButtonContainer onPress={this.handleDataModalClose}>
            <ContinueButtonText>Continuar</ContinueButtonText>
          </CameraButtonContainer>
        </ModalButtons>
      </ModalContainer>
    </Modal>
  )

Essa função é a responsável por criar um Modal na tela, cuja exibição é controlada pela variável cameraModalOpened do state do componente, dentro do Modal é criada uma estrutura que contém na parte superior a câmera com um botão para capturar a imagem, na parte inferior 2 botões, um para continuar para a próxima tela e um para cancelar, e no meio disso quando houverem imagens capturadas pela câmera irá aparecer uma lista com miniaturas das imagens capturadas.

Obs.: Não se preocupe, a câmera só ficou assim por estar em um emulador.

Para que essa parte funcione corretamente é necessário que sejam criadas algumas funções que são chamadas dentro da função renderCameraModal, são elas: handleTakePicture, renderImagesList, handleCameraModalClose e handleDataModalClose, segue abaixo o código delas:

handleTakePicture = async () => {
  if (this.camera) {
    const options = { quality: 0.5, base64: true, forceUpOrientation: true, fixOrientation: true, };
    const data = await this.camera.takePictureAsync(options)
    const { realtyData } = this.state;
    this.setState({ realtyData: {
      ...realtyData,
      images: [
        ...realtyData.images,
        data,
      ]
    }})
  }
}

renderImagesList = () => (
  this.state.realtyData.images.length !== 0 ? (
    <ModalImagesListContainer>
      <ModalImagesList horizontal>
        { this.state.realtyData.images.map(image => (
          <ModalImageItem source={{ uri: image.uri }} resizeMode="stretch" />
        ))}
      </ModalImagesList>
    </ModalImagesListContainer>
  ) : null
)

handleCameraModalClose = () => this.setState({ cameraModalOpened: !this.state.cameraModalOpened })

handleDataModalClose = () => this.setState({
  dataModalOpened: !this.state.dataModalOpened,
  cameraModalOpened: false,
})

Agora uma breve explicação do funcionamento de cada uma:

  • handleCameraModalClose e handleDataModalClose: Ambas tem a função apenas de fazer um toggle (inversão) do valor do state das variáveis cameraModalOpened e dataModalOpened;
  • handleTakePicture: Essa função captura a imagem da câmera e adiciona no array images dentro de realtyData no state;
  • renderImagesList: Essa função não renderiza nada caso não haja imagens capturadas, mas caso haja faz um map no array de imagens e lista cada um em um <Image> dentro de um <ScrollView> horizontal.

E assim acaba mais uma parte da sequência de cadastro, a próxima e última parte é inserir as demais informações e fazer o cadastro na API.

Caso você tenha alguma dúvida ou problema pode conferir o código da aplicação nesse link.

Criação da Tela de Informações do Imóvel e efetivando o cadastro na API

Nessa última parte vamos criar outro Modal para listar e preencher os últimos dados do imóvel, antes de realizar o cadastro na API, por isso para iniciar vamos criar os estilos, mas como ficou um pouco extenso deixei disponível nesse link, basta copiar todo o conteúdo e colar no seu arquivo src/pages/main/styles.js.

Feito isso adicione todos os componentes no import do arquivo src/pages/main/index.js.

Agora sim podemos criar a função para renderizar o Modal, para isso vamos criar uma função renderDataModal, seu código vai ficar assim:

renderDataModal = () => (
  <Modal
    visible={this.state.dataModalOpened}
    transparent={false}
    animationType="slide"
    onRequestClose={this.handleDataModalClose}
  >
    <ModalContainer>
      <ModalContainer>
        <MapboxGL.MapView
          centerCoordinate={[
            this.state.realtyData.location.longitude,
            this.state.realtyData.location.latitude
          ]}
          style={{ flex: 1 }}
          styleURL={MapboxGL.StyleURL.Dark}
        >
          <MapboxGL.PointAnnotation
            id="center"
            coordinate={[
              this.state.realtyData.location.longitude,
              this.state.realtyData.location.latitude
            ]}
          >
            <MarkerContainer>
              <MarkerLabel />
            </MarkerContainer>
          </MapboxGL.PointAnnotation>
        </MapboxGL.MapView>
      </ModalContainer>
      { this.renderImagesList() }
      <Form>
        <Input
          placeholder="Nome do Imóvel"
          value={this.state.realtyData.name}
          onChangeText={name => this.handleInputChange('name', name)}
          autoCapitalize="none"
          autoCorrect={false}
        />
        <Input
          placeholder="Endereço"
          value={this.state.realtyData.address}
          onChangeText={address => this.handleInputChange('address', address)}
          autoCapitalize="none"
          autoCorrect={false}
        />
        <Input
          placeholder="Preço"
          value={this.state.realtyData.price}
          onChangeText={price => this.handleInputChange('price', price)}
          autoCapitalize="none"
          autoCorrect={false}
        />
      </Form>
      <DataButtonsWrapper>
        <SelectButtonContainer onPress={this.saveRealty}>
          <ButtonText>Salvar Imóvel</ButtonText>
        </SelectButtonContainer>
        <CancelButtonContainer onPress={this.handleDataModalClose}>
          <ButtonText>Cancelar</ButtonText>
        </CancelButtonContainer>
      </DataButtonsWrapper>
    </ModalContainer>
  </Modal>
)

Como vocês perceberam ele ficou um pouco longo, mas isso porque temos mais componentes do que lógica nesse Modal, essa função é bem semelhante à do Modal de câmera onde temos um Modal com um Container, dentro dele temos os componentes que serão renderizados, e nesse Modal temos no começo um Mapa como na Tela Principal, a diferença é que dentro dele temos um Marker para a localização selecionada, depois disso temos a lista de imagens como na tela anterior, no final temos novamente 2 botões, a diferença principal da outra tela é a presença do Form com 3 campos para preenchimento de informações, e é nesse form que vamos utilizar as variáveis name, address e price do objeto realtyData do State.

E para finalizar vamos criar as funções usadas pelo Modal para que não haja erro, temos que criar as funções handleInputChange e saveRealty, primeiro vou postar o código da função que trata a mudança dos Inputs para depois focarmos na função de cadastro do Imóvel.

handleInputChange = (index, value) => {
    const { realtyData } = this.state;
    switch (index) {
      case 'name':
        this.setState({ realtyData: {
          ...realtyData,
          name: value,
        }});
        break;
      case 'address':
        this.setState({ realtyData: {
          ...realtyData,
          address: value,
        }});
        break;
      case 'price':
        this.setState({ realtyData: {
          ...realtyData,
          price: value,
        }});
        break;
    }
}

As funções acima tem o objetivo apenas de mudar a variável referente ao campo no state.

E agora sim, a cereja do bolo (aeeeee 😃), a função que irá efetivar o cadastro do imóvel na API, a saveRealty, primeiro vou postar seu código para depois abordar a lógica usada, e como o código ficou um pouco extenso resolvi deixar disponível nesse link basta copiar esse código e colar no seu componente junto com as demais funções, mas não se preocupe, como eu já disse, vou quebrá-la em partes e explicar a lógica de cada trecho.

O primeiro trecho que encontramos é o seguinte:

const { 
    realtyData: {
      name,
      address,
      price,
      location: {
        latitude,
        longitude
      },
      images
    }
} = this.state;

Esse trecho é uma desestruturação do state para obtermos todos os dados referentes ao imóvel, o próximo trecho é:

const newRealtyResponse = await api.post('/properties', {
  title: name,
  address,
  price,
  latitude: Number(latitude.toFixed(6)),
  longitude: Number(longitude.toFixed(6)),
});

Esse trecho estamos enviando à API uma requisição POST com os dados do imóvel para que haja o cadastro efetivo do imóvel e nos seja retornado um objeto com o id do imóvel no banco de dados, que iremos usar logo na sequência.

O próximo trecho é o ponto chave dessa função, ele é o responsável por organizar as imagens para serem enviadas corretamente para a API e serem movidas para o servidor e cadastradas no banco de dados, segue o código:

const imagesData = new FormData();

images.forEach((image, index) => {
  imagesData.append('image', {
    uri: image.uri,
    type: 'image/jpeg',
    name: `${newRealtyResponse.data.title}_${index}.jpg`
  });
});

Nesse trecho usamos o FormData que é um recurso que nos permite criar um conjunto de pares chave/valor representando campos de um formulário com seus respectivos valores, e é ele que iremos usar para enviar as imagens, por isso logo abaixo recuperamos o array images do state e usamos um forEach para percorrer cada índice dele, e a cada índice é adicionado no FormData uma posição com o nome image e como valor um objeto contendo o endereço da imagem, o tipo dela e também um nome único.

Agora com as imagens organizadas vamos ao envio da requisição:

await api.post(
  `/properties/${newRealtyResponse.data.id}/images`,
  imagesData,
);

Nesse trecho apenas usamos o Axios para enviar uma requisição para a rota /properties/:id/images da API com o FormData previamente formatado.

E por último, antes de chamar as 3 últimas funções para finalizar a parte de cadastro, temos que fazer uma pequena mudança, no vamos tirar o código da requisição que está no componentDidMount e passar para uma função separada, que será usada logo na sequência, ficando dessa maneira:

componentDidMount() {
  this.getLocation();
}

getLocation = async () => {
  try {
    const response = await api.get('/properties', {
      params: {
        latitude: -27.210768,
        longitude: -49.644018,
      },
    });

    this.setState({ locations: response.data });
  } catch (err) {
    console.tron.log(err);
  }
}

Agora sim, vamos à chamada das funções no final da saveRealty, ficando assim:

this.getLocation();
this.setState({ newRealty:false });
this.handleDataModalClose();

Elas basicamente atualizam a lista de imóveis no state, depois é voltado o botão de Novo Imóvel na tela do mapa e por último é fechado o Modal de Dados.

Com tudo isso feito sua aplicação deve funcionar corretamente permitindo todo o processo de cadastro de um novo imóvel, você também pode conferir o código completo nesse link para comparar com o seu ou realizar testes caso tenha alguma dúvida ou problema 😉

Considerações Finais

Como você percebeu essa parte ficou um pouco mais extensa que as anteriores, mas isso devido à complexidade do processo de cadastro e do número de etapas necessárias.

A aplicação nesse ponto está quase pronta, falta apenas a listagem dos detalhes dos imóveis, mas isso é para a próxima parte, afinal já temos uma aplicação que faz Autenticação usando JWT, uso de Mapas, uso de Câmera, Upload de Imagens, dentre outras funcionalidades que já implementamos, percebe o quanto a aplicação está completa e com recursos que no dia a dia são um diferencial em uma aplicação!?

Apenas reforçando, você pode conferir o código final nesse link 😉

Espero que tenha gostado, deixe seu comentário/dúvida/crítica/sugestão que é muito importante para nós 😃

Até a próxima parte \o/