How to create map like in airbnb with react and google-maps
Alexander Dmitriev
Posted on September 7, 2021
Introduction
Disclaimer
- English is not my native language, so there may be mistakes in the text, but I'm sure that the code can say a thousand times more than any of my words
- I did not use react-map-libraries to make the solution as flexible and understandable as possible
- This is the most simple implementation without deep styling, clustering and global-storage.
- Source code is here - https://github.com/alex1998dmit/map_airbnb
Task
Let's assume that we need to create a map with displaying apartment cards on it as it is done on airbnb
The technology stack is next:
- React & Typescript
- Google map and @googlemaps/react-wrapper
- MUI for style stuff
Implementation
Create our app
Everything is pretty trivial - you need to install the application using creat-react-app using TS
npx create-react-app my-app --template typescript
Install dependecies
For the application to work, we need MUI, @googlemaps/react-wrapper
npm install --save @material-ui/core @material-ui/icons @googlemaps/react-wrapper
Configure map
In this step we will integrate simple map to application. First of all you need to get google-map key - [https://developers.google.com/maps/documentation/javascript/get-api-key].
First of all let's create a Map component, which will be wrapper for google-maps.
import { useEffect, useRef, useState } from "react";
// we will use make styles for styling components, you can use another solutions (like css, sass or cssonjs
import { makeStyles } from "@material-ui/core";
// api mock data
import Apartments from "./apartments";
// Our component will receive center coords and zoom size in props
type MapProps = {
center: google.maps.LatLngLiteral
zoom: number
}
// map wrapper styles
const useStyles = makeStyles({
map: {
height: '100vh'
}
})
function Map({ center, zoom }: MapProps) {
const ref = useRef(null);
const [map, setMap] = useState<google.maps.Map<Element> | null>(null)
const classes = useStyles();
useEffect(() => {
// we need to save google-map object for adding markers and routes in future
if (ref.current) {
// here will connect map frame to div element in DOM by using ref hook
let createdMap = new window.google.maps.Map(
ref.current,
{
center,
zoom,
disableDefaultUI: true,
clickableIcons: false
}
);
setMap(createdMap)
}
}, [center, zoom]);
// map will be connect to this div block
return <div ref={ref} id="map" className={classes.map} />;
}
export default Map
Then let's modify App.tsx with:
import React, { ReactElement } from 'react';
import { Wrapper, Status } from "@googlemaps/react-wrapper";
import Map from './Map'
// Here we can add views when map will loading or failure
const render = (status: Status): ReactElement => {
if (status === Status.LOADING) return <h3>{status} ..</h3>;
if (status === Status.FAILURE) return <h3>{status} ...</h3>;
return <></>;
};
function App() {
if (!process.env.REACT_APP_GOOGLE_KEY) {
return <h2>Add google key</h2>
}
return (
<div className="App">
<Wrapper apiKey={process.env.REACT_APP_GOOGLE_KEY} render={render}>
<Map center={{ lat: 55.753559, lng: 37.609218 }} zoom={11} />
</Wrapper>
</div>
);
}
export default App;
And result is:
Add custom overlays
The next step is to add a custom overlay. Why will we use overlays and not markers ? Because in my opinion it will be difficult to customize regular markers, according to the documentation we can change only icon image and label over it [https://developers.google.com/maps/documentation/javascript/custom-markers].
Let's create an OverlayContainer, which will be a wrapper for the components located on the map at certain coordinates.
import * as React from 'react'
import ReactDOM from 'react-dom';
// base function for creating DOM div node
function createOverlayElement() {
const el = document.createElement('div');
el.style.position = 'absolute';
el.style.display = 'inline-block';
el.style.width = '9999px';
return el;
}
// Our OverlayComponent will recieve map, postion and children props - position is coords, map is google.map object and children is a component that will be render in overlay
export type Props = {
map: google.maps.Map | null
position: { lat: number, lng: number }
children?: React.ReactChild
}
const OverlayContainer = (props: Props) => {
const overlay = React.useRef<google.maps.OverlayView | null>(null)
const el = React.useRef<Element | null>(null)
// modified OverlayView from google.maps [https://developers.google.com/maps/documentation/javascript/reference/3.44/overlay-view?hl=en]
class OverlayView extends window.google.maps.OverlayView {
position: google.maps.LatLng | null = null;
content: any = null;
constructor(props: any) {
super();
props.position && (this.position = props.position);
props.content && (this.content = props.content);
}
onAdd = () => {
if (this.content) this.getPanes().floatPane.appendChild(this.content);
};
onRemove = () => {
if (this.content?.parentElement) {
this.content.parentElement.removeChild(this.content);
}
};
draw = () => {
if (this.position) {
const divPosition = this.getProjection().fromLatLngToDivPixel(
this.position
);
this.content.style.left = divPosition.x + 'px';
this.content.style.top = divPosition.y + 'px';
}
};
}
React.useEffect(() => {
return () => {
if (overlay.current) overlay.current.setMap(null)
}
}, [])
if (props.map) {
el.current = el.current || createOverlayElement()
overlay.current = overlay.current || new OverlayView(
{
position: new google.maps.LatLng(props.position.lat, props.position.lng),
content: el.current
}
)
overlay.current.setMap(props.map)
return ReactDOM.createPortal(props.children, el.current);
}
return null
}
export default OverlayContainer
Creating Map Points and Apartment Cards
I will create a simple apartment card by using MUI-core and MUI-icons [https://material-ui.com/ru/components/cards/] [https://material-ui.com/ru/components/material-icons/].
Let's create ApartmentCard:
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardActionArea from '@material-ui/core/CardActionArea';
import CardContent from '@material-ui/core/CardContent';
import CardMedia from '@material-ui/core/CardMedia';
import Typography from '@material-ui/core/Typography';
import AspectRatioIcon from '@material-ui/icons/AspectRatio';
import { Grid, IconButton } from '@material-ui/core';
import MeetingRoomIcon from '@material-ui/icons/MeetingRoom';
import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
import CloseIcon from '@material-ui/icons/Close';
const useStyles = makeStyles({
root: {
maxWidth: 230,
position: 'relative',
zIndex: 1001,
},
media: {
height: 100,
},
close: {
position: 'absolute',
left: 0,
top: 0,
zIndex: 1001,
background: 'white',
width: '25px',
height: '25px'
}
});
type ApartmentCardProps = {
image: string
address: string
area: number
rooms_number: number
floor: number
floor_count: number
rent: number
handleClose: () => void
}
export default function ApartmentCard(props: ApartmentCardProps) {
const classes = useStyles();
return (
<Card className={classes.root}>
<IconButton className={classes.close} aria-label="close" onClick={props.handleClose}>
<CloseIcon />
</IconButton>
<CardActionArea>
<CardMedia
className={classes.media}
image={props.image}
title="Contemplative Reptile"
/>
<CardContent>
<Typography variant="body2" component="h2">
{props.address}
</Typography>
<Grid container spacing={1}>
<Grid item container xs={6} spacing={1} alignItems='center'>
<Grid item xs={8}><AspectRatioIcon /></Grid>
<Grid item xs={4}>{props.area}</Grid>
</Grid>
<Grid item container xs={6} spacing={1} alignItems='center'>
<Grid item xs={8}><MeetingRoomIcon /></Grid>
<Grid item xs={4}>{props.rooms_number}</Grid>
</Grid>
<Grid item container xs={6} spacing={1} alignItems='center'>
<Grid item xs={8}><KeyboardArrowUpIcon /></Grid>
<Grid item xs={4}>{props.floor}/{props.floor_count}</Grid>
</Grid>
<Grid item container xs={12} spacing={1} alignItems='center' justifyContent="center">
<Typography variant="body2" style={{ fontWeight: 600 }}>{props.rent} $</Typography>
</Grid>
</Grid>
</Typography> */}
</CardContent>
</CardActionArea>
</Card>
);
}
And ApartmentPoint:
import { makeStyles } from "@material-ui/styles"
type ApartmentPonitProps = {
price: number
onClick: () => void
}
const styles = makeStyles({
root:{
background: 'white',
borderRadius: '12px',
padding: '8px',
width: '60px',
zIndex: 1000,
position: 'relative'
}
})
const ApartmentPoint = (props: ApartmentPonitProps) => {
const classes = styles()
return (
<div className={classes.root} onClick={props.onClick}>
{props.price} $
</div>
)
}
export default ApartmentPoint
We will use MapPoint like wrapper that will render ApartmentPoint or ApartmentCard:
import { useEffect, useRef, useState } from "react"
import ApartmentCard from "./ApartmentCard"
import ApartmentPoint from "./ApartmentPoint"
type MapPointProps = {
image: string
address: string
area: number
rooms_number: number
floor: number
floor_count: number
rent: number
}
const MapPoint = (props: MapPointProps) => {
const [opened, setIsOpened] = useState<boolean>(false)
const handleOnOpen = () => setIsOpened(true)
const handleOnClose = () => setIsOpened(false)
const containerRef = useRef<HTMLDivElement>(null)
// Hook for handle outside click - simple implementation from stack overflow
useEffect(() => {
function handleClickOutside(this: Document, event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpened(false)
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [containerRef]);
return (<div ref={containerRef}>
{opened ?
<ApartmentCard
image={props.image}
address={props.address}
area={props.area}
rooms_number={props.rooms_number}
floor={props.floor}
floor_count={props.floor_count}
rent={props.rent}
handleClose={handleOnClose}
/> :
<ApartmentPoint
price={props.rent}
onClick={handleOnOpen}
/>}
</div>)
}
export default MapPoint
And all together
Let's modify Map component by adding apartments points inside overlay containers:
import { makeStyles } from "@material-ui/core";
import { useEffect, useRef, useState } from "react";
import Apartments from "./apartments";
import MapPoint from "./MapPoint";
import OverlayContainer from "./OverlayContainer";
type MapProps = {
center: google.maps.LatLngLiteral
zoom: number
}
const useStyles = makeStyles({
map: {
height: '100vh'
}
})
function Map({ center, zoom }: MapProps) {
const ref = useRef(null);
const [map, setMap] = useState<google.maps.Map<Element> | null>(null)
const classes = useStyles();
useEffect(() => {
if (ref.current) {
let createdMap = new window.google.maps.Map(
ref.current,
{
center,
zoom,
disableDefaultUI: true,
clickableIcons: false
}
);
setMap(createdMap)
}
}, [center, zoom]);
return <div ref={ref} id="map" className={classes.map}>
{Apartments.map((apartment, index) => (
<OverlayContainer
map={map}
position={{
lat: apartment.lat,
lng: apartment.lng
}}
key={index}
>
<MapPoint
image={apartment.image}
address={apartment.address}
area={apartment.area}
rooms_number={apartment.rooms_number}
floor={apartment.floor}
floor_count={apartment.floor_count}
rent={apartment.rent}
/>
</OverlayContainer>
))}
</div>;
}
export default Map
Apartments mock-data example(apartments.ts):
const Apartments = [
{
"id": 1,
"image": "https://storage.yandexcloud.net/apartment-images/2.jpg",
"area": 34.9,
"kitchen_area": null,
"address": "Novoalekseevskaya 4d4",
"lat": 55.80562399999999,
"lng": 37.641239,
"rooms_number": 1,
"bedrooms_number": 1,
"restrooms_number": 1,
"floor": 3,
"floor_count": 14,
"rent": 1500
},
{
"id": 2,
"image": "https://storage.yandexcloud.net/apartment-images/10_S939Rcf.jpg",
"area": 47,
"kitchen_area": null,
"address": "Valovaya street 31",
"lat": 55.66497999999999,
"lng": 37.857464,
"rooms_number": 1,
"bedrooms_number": 1,
"restrooms_number": 1,
"floor": 6,
"floor_count": 9,
"rent": 2000
},
{
"id": 3,
"image": "https://storage.yandexcloud.net/apartment-images/07_uvV7gIk.jpg",
"area": 40.9,
"kitchen_area": null,
"address": "academic Volgyn street 8A",
"lat": 55.68271799999999,
"lng": 37.544263,
"rooms_number": 3,
"bedrooms_number": 2,
"restrooms_number": 1,
"floor": 2,
"floor_count": 5,
"rent": 3000
}
]
export default Apartments
Result
P.S.
This is just the first article, in it I tried only to show how I work with google maps and react, in further articles there will be more logic and styling to get as close as possible to airbnb
Posted on September 7, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.