React-google-maps: Add a simple custom control class

Created on 25 Jul 2016  路  12Comments  路  Source: tomchentw/react-google-maps

Currently, it is not entirely clear how to add custom control elements to the map. You can dig through the SearchBox and SearchBoxCreator source to figure it out, but that seems like an unnecessary amount of work for potential devs when a working implementation is quite simple (plus, it has some intricacies due to using an input element).

Example Implementation

(this can be submitted as a PR as well, but it's very short and simple)

import { Component } from 'react';

export default class CustomControl extends Component {
  addToMap(root) {
    const { mapHolderRef, controlPosition } = this.props;
    mapHolderRef.getMap().controls[controlPosition].push(root);
  }

  render() {
    <div ref={this.addToMap}>{this.props.children}</div>
  }
}

Example Usage

import { Component } from 'react';
import { GoogleMapLoader, GoogleMap, CustomControl } from 'react-google-maps';

export default class UsageExample extends Component {
  render() {
    <GoogleMapLoader containerElement={<div style={{height: '100%'}} />}
                     googleMapElement={
                       <GoogleMap defaultZoom={3} defaultCenter={{ lat: -25.363882, lng: 131.044922 }}>
                         <CustomControl controlPosition{google.maps.ControlButton.RIGHT_BOTTOM}>
                              <p>Hello, World</p>
                         </CustomControl>
                       </GoogleMap>
                     } />
  }
}

Most helpful comment

@ashtonsix this was a lifesaver for me. For anyone that needs it, here is a tweak of it implemented in ES6, and using the same internal conventions for referencing the map via the context api so that you don't have to pass in a reference to it.

import React from 'react';
import PropTypes from 'prop-types';
import { render } from 'react-dom';
import { MAP } from 'react-google-maps/src/lib/constants';

class MapControl extends React.Component {
    static contextTypes = {
        [MAP]: PropTypes.object
    }

    static propTypes = {
        controlPosition: PropTypes.number
    }

    static defaultProps = {
        controlPosition: google.maps.ControlPosition.TOP_LEFT
    }

    componentDidMount() {
        this.map = this.context[MAP];
        this._render();
    }

    componentDidUpdate() {
        this._render();
    }

    componentWillUnmount() {
        const {controlPosition} = this.props;
        const index = this.map.controls[controlPosition].getArray().indexOf(this.el);
        this.map.controls[controlPosition].removeAt(index);
    }
    _render() {
        const {controlPosition, children} = this.props;

        render(
            <div ref={el => {
                if (!this.renderedOnce) {
                    this.el = el;
                    this.map.controls[controlPosition].push(el);
                } else if (el && this.el && el !== this.el) {
                    this.el.innerHTML = '';
                    [].slice.call(el.childNodes).forEach(child => this.el.appendChild(child));
                }
                this.renderedOnce = true;
            }}>
                {children}
            </div>,
            document.createElement('div')
        );
    }

    render() {
        return <noscript />;
    }
}

All 12 comments

I defined MapControl like:

import React from 'react'
import ReactDOM from 'react-dom'

// Enables custom elements within <GoogleMap>
// Children shouldn't change height between renders
export const MapControl = React.createClass({
  componentDidMount() { this._render() },
  componentDidUpdate() { this._render() },
  componentWillUnmount() {
    const {mapHolderRef, controlPosition} = this.props
    const index = mapHolderRef.getMap().controls[controlPosition].getArray().indexOf(this.el)
    mapHolderRef.getMap().controls[controlPosition].removeAt(index)
  },
  _render() {
    const {mapHolderRef, controlPosition, children} = this.props
    ReactDOM.render(
      <div
        ref={el => {
          const controlSet = mapHolderRef.getMap().controls[controlPosition]
          if (!this.renderedOnce) {
            this.el = el
            controlSet.push(el)
          } else if (el && this.el && el !== this.el) {
            this.el.innerHTML = '';
            [].slice.call(el.childNodes).forEach(child => this.el.appendChild(child))
          }
          this.renderedOnce = true
        }}
      >
        {children}
      </div>,
      document.createElement('div')
    )
  },
  render() {
    return <noscript />
  },
})

export default MapControl

This plays nicely when React needs to re-render or unmount controls

@PsychicNoodles @ashtonwar this could be easily done in 6.0.0 since we wrote it. Would you like to submit a PR?

Also, 6.0.0 is released on npm beta tag now. We also have a new demo page. Feel free to try it:
https://tomchentw.github.io/react-google-maps/

@ashtonsix this was a lifesaver for me. For anyone that needs it, here is a tweak of it implemented in ES6, and using the same internal conventions for referencing the map via the context api so that you don't have to pass in a reference to it.

import React from 'react';
import PropTypes from 'prop-types';
import { render } from 'react-dom';
import { MAP } from 'react-google-maps/src/lib/constants';

class MapControl extends React.Component {
    static contextTypes = {
        [MAP]: PropTypes.object
    }

    static propTypes = {
        controlPosition: PropTypes.number
    }

    static defaultProps = {
        controlPosition: google.maps.ControlPosition.TOP_LEFT
    }

    componentDidMount() {
        this.map = this.context[MAP];
        this._render();
    }

    componentDidUpdate() {
        this._render();
    }

    componentWillUnmount() {
        const {controlPosition} = this.props;
        const index = this.map.controls[controlPosition].getArray().indexOf(this.el);
        this.map.controls[controlPosition].removeAt(index);
    }
    _render() {
        const {controlPosition, children} = this.props;

        render(
            <div ref={el => {
                if (!this.renderedOnce) {
                    this.el = el;
                    this.map.controls[controlPosition].push(el);
                } else if (el && this.el && el !== this.el) {
                    this.el.innerHTML = '';
                    [].slice.call(el.childNodes).forEach(child => this.el.appendChild(child));
                }
                this.renderedOnce = true;
            }}>
                {children}
            </div>,
            document.createElement('div')
        );
    }

    render() {
        return <noscript />;
    }
}

@jamesmfriedman Bless you. This was the only way I could get this working properly without directly interacting with the scary ref.context.__SECRET_MAP_DO_NOT_USE_OR_YOU_WILL_BE_FIRED value in ^9.2.2. The only fix I needed for this version was to remove src from the MAP import.

If inside <MapControl> you use <Link> or i18n or theme or any other context based component it will not work. To fix it replace render to unstable_renderSubtreeIntoContainer in the snipped above (and add this as first argument):

// instead of
render(<div ref={...}>...</div>, document.createElement('div'))
// do
unstable_renderSubtreeIntoContainer(this, <div ref={...}>...</div>, document.createElement('div'))

UPDATE:
unstable_renderSubtreeIntoContainer made my components inside ignore shouldComponentUpdate method. So I rewrote MapContainer component using react-portal to be able use shouldComponentUpdate.

Could any one of you submit a PR? LOL

@tomchentw - have you considered creating a PR yourself?

Is @jamesmfriedman way the go to way of adding custom controls at the moment? I tried to look for a custom controls but could not find it in the docs

I am receiving a google is not defined when putting it into it's own file. I'm on v9.4.1

@quachsimon you have to have google maps added via a script tag, or explicitly import it

How would I explicitly import it in the file?

@quachsimon http://bfy.tw/FRuX ;)

Is this still not possible? I'll admit that I'm still learning React and this library, but I've created the following based on the code for existing components:

import canUseDOM from 'can-use-dom';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { PureComponent } from 'react';
import ReactDOM from 'react-dom';

import { componentWillUnmount } from 'react-google-maps/lib/utils/MapChildHelper';

import { MAP } from 'react-google-maps/lib/constants';

/**
 * A wrapper around any component as a control on the map
 *
 * @see https://developers.google.com/maps/documentation/javascript/reference/3.exp/#control
 */
export class Control extends PureComponent {
  static propTypes = {
    /**
     * Where to put `<Control>` inside a `<GoogleMap>`
     *
     * @example google.maps.ControlPosition.TOP_LEFT
     * @type number
     */
    controlPosition: PropTypes.number
  }

  static contextTypes = {
    [MAP]: PropTypes.object,
  }

  componentWillMount() {
    if (!canUseDOM || this.containerElement) {
      return;
    }
    this.containerElement = document.createElement('div');
    this.handleRenderChildToContainerElement();
    if (React.version.match(/^16/)) {
      return;
    }
  }

  componentDidMount() {
    this.handleMountAtControlPosition();
  }

  componentWillUpdate(nextProp) {
    if (this.props.controlPosition !== nextProp.controlPosition) {
      this.handleUnmountAtControlPosition();
    }
  }

  componentDidUpdate(prevProps) {
    if (this.props.children !== prevProps.children) {
      this.handleRenderChildToContainerElement();
    }
    if (this.props.controlPosition !== prevProps.controlPosition) {
      this.handleMountAtControlPosition();
    }
  }

  componentWillUnmount() {
    componentWillUnmount(this);
    this.handleUnmountAtControlPosition();
    if (React.version.match(/^16/)) {
      return;
    }
    if (this.containerElement) {
      ReactDOM.unmountComponentAtNode(this.containerElement);
      this.containerElement = null;
    }
  }

  handleRenderChildToContainerElement() {
    if (React.version.match(/^16/)) {
      return;
    }
    ReactDOM.unstable_renderSubtreeIntoContainer(this, React.Children.only(this.props.children), this.containerElement);
  }

  handleMountAtControlPosition() {
    if (isValidControlPosition(this.props.controlPosition)) {
      this.mountControlIndex = -1 + this.context[MAP].controls[this.props.controlPosition].push(
        this.containerElement.firstChild);
    }
  }

  handleUnmountAtControlPosition() {
    if (isValidControlPosition(this.props.controlPosition)) {
      const child = this.context[MAP].controls[this.props.controlPosition].removeAt(this.mountControlIndex);
      if (child !== undefined) {
        this.containerElement.appendChild(child);
      }
    }
  }

  render() {
    if (React.version.match(/^16/)) {
      return ReactDOM.createPortal(React.Children.only(this.props.children), this.containerElement);
    }
    return false;
  }
}

export default Control;

const isValidControlPosition = _.isNumber;

It seems like overkill to me, but it works.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

bossbossk20 picture bossbossk20  路  3Comments

craigcartmell picture craigcartmell  路  4Comments

madbean picture madbean  路  3Comments

EvHaus picture EvHaus  路  3Comments

tahir-masood1 picture tahir-masood1  路  4Comments