Back in the looney days before russian full-scale invasion, I’ve used to travel a lot domestically and internationally. Also at that time, I had a Nokia Lumia phone with a nice widget that displayed the locations of the photos I’ve taken on a map. I enjoyed looking at it realizing how big is the world and how much I have to travel in order to discover it.

While I have a similar widget in my Android phone I wasn’t satisfied with its look and feel. Another issue for me was that the last time I touched fron-end was 4 years ago and since then I hadn’t much experience with modern SPA-frameworks. The desire to bridge that gap and to have the app I wanted motivated me to create my own.

The ultimate result looks like this.

In case you want to try out the app you can download it from the Play Market. The source code is available on Github in case you want to follow along with the code in the article.

Constraints and tradeoffs

The reason behind choosing my tech stack was to get to know modern SPA frameworks better. So naturally, my choice was React Native. As a novice in mobile development, I decided to start with Expo. It is a nice way to start the project since it contains a couple of templates as well as a nice debugging toolset.

What I’ve learned is that the convenience of Expo is not free. Here are some disadvantages: - Implicit integration with Facebook SDK - Depreciation of some crucial libs such as expo-ads-admob - Deprecation of classic build.

However, these shortcomings can be remediated with EAS build.

Visualization algorithm

In order to provide the best possible look and feel we need to give a user a feeling that something is happening while the application is enumerating the photos and obtaining their geodata. So rendering sequence looks as below:

  1. Display a splash screen
  2. Replace it with a screen that resembles a splash screen but additionally provides information about loading progress.
  3. Once photos are processed, display a map with the points.

Let’s briefly look at these points.

Displaying progress information

The root component code is provided below

SplashScreen.preventAutoHideAsync().catch(() => {
  /* reloading the app might trigger some race conditions, ignore them */
});

const App = () => {
  return (
    <AnimatedAppLoader/>
  );
}
Pretty straightforward. We prevent the automatic hiding of a splashscreen and render a single component. Let’s dive deeper into the code of this component.
  return (
      <View style={{ flex: 1 }}>
        <AnimatedSplashScreen/>
      </View>
    )
  }
The point of our interest is AnimatedSplashScreen component.
const AnimatedSplashScreen = () => {
    const textAnimation = useMemo(() => new Animated.Value(0), []);
    const [isAppReady, setAppReady] = useState(false);
    const [isTextAnimationIsReady, setTextAnimationIsReady] = useState(false);
    const [loadingText, setLoadingText] = useState("");

    useEffect(() => {
      if (!isAppReady && isTextAnimationIsReady) {
        Animated.timing(textAnimation, {
          toValue: 1,
          duration: 200,
          easing: Easing.inOut(Easing.exp),
          useNativeDriver: true,
        }).start();
      }
    }, [isAppReady, isTextAnimationIsReady]);

    const loadLocations = async () => {
      let markersArray : MediaLibrary.Location[] = [];
      let hasMoreData = true;
      try {
        while (hasMoreData) {
          //loading imahe locations is omitted by now
          let now = Date.now();
          let delta = now - timeStart;
          if (delta > 9000) {
            setLoadingText(`We've processed ${cursor.endCursor} items. There's more work though...`)
          } else if (delta > 5000) {
            setLoadingText("Working on it...")
          } else if (delta > 2000) {
            if (!isTextAnimationIsReady) {
              setTextAnimationIsReady(true);
            }
            setLoadingText("Hold on! We're doing some magic just for you...")
          }
        }
      } catch (e) {
        console.log(e)
      } finally {
        setAppReady(!hasMoreData);
      }
    }

    const onImageLoaded = useCallback(async () => {
      await SplashScreen.hideAsync();
      await loadLocations();
    }, []);

    return (
      <View style={{ flex: 1 }}>
        {isAppReady  && (<MainScreen/>)}
        {!isAppReady && (<Animated.View
          pointerEvents="none"
          style={[
            {
              backgroundColor: Constants.manifest?.splash?.backgroundColor
            },
          ]}>
          <Animated.Text>
            {loadingText}
          </Animated.Text>

          <Animated.Image
            source={require("../assets/splash.png")}
            onLoadEnd={onImageLoaded}
            fadeDuration={0}
          />
        </Animated.View>)}

      </View>
    );
  }
What happens here is that we replace our splash screen with Animated.Image that looks exactly like the splash screen. Once the image is ready we call onImageLoaded callback. Now when our image is ready we can hide the splashscreen and call loadLocations. This method performs the work needed to enumerate location of our photos and meanwhile takes charge of animation to keep the user engaged.

For now, everything we need to know about loadLocations method is that it does image processing batch by batch which is indicated by while (hasMoreData). This means that inside each loop iteration, we can check how much time elapsed and trigger text animation accordingly. Once it’s time to display text animation we update the state with setTextAnimationIsReady(true);. This in turn triggers Animated.timing. Which changes the opacity of Animated.Text.

Once we’re done we update the state with setAppReady and substitute our animation component with MainScreen.

Loading image location

In order to enumerate the contents of our gallery we’ll use Expo Medialibrary.

let medialibraryRequest : MediaLibrary.AssetsOptions = {}

const loadLocations = async () => {
  let markersArray : MediaLibrary.Location[] = [];
  let hasMoreData = true;
  try {
    let { status } = await MediaLibrary.requestPermissionsAsync();
    let markersSet : Set<MediaLibrary.Location> = new Set();
    const albums = await MediaLibrary.getAlbumsAsync();
    const cameraAlbum = albums.find(p => p.title === "Camera");
    medialibraryRequest.album = cameraAlbum;
    while (hasMoreData) {
      let cursor = await MediaLibrary.getAssetsAsync(medialibraryRequest);
      await populateLocationsIntoSet(cursor, markersSet);
      hasMoreData = cursor.hasNextPage;
      medialibraryRequest.after = cursor.endCursor
    }
    markersArray = [...markersSet]
    setMarkers(markersArray);
  } catch (e) {
    console.log(e)
  } finally {
    setAppReady(!hasMoreData);
  }
}

const populateLocationsIntoSet = async (
  cursor : MediaLibrary.PagedInfo<MediaLibrary.Asset>,
  markersSet : Set<MediaLibrary.Location>) => {
    const allowedTypes : MediaLibrary.MediaTypeValue[] = [
      MediaLibrary.MediaType.photo,
      MediaLibrary.MediaType.video
    ]
    const markersArray = await Promise.all(cursor.assets.map(async element => {
      if (!allowedTypes.includes(element.mediaType)) {
        return;
      }
      let image = await MediaLibrary.getAssetInfoAsync(element);
      return image.location;
    }));
    if (markersArray.length === 0) {
      return;
    }
    let nonNullLocations = markersArray.filter(p => p != undefined) as MediaLibrary.Location[];
    nonNullLocations.forEach(markersSet.add, markersSet);
}

We start off with requesting permissions to access the media library using requestPermissionsAsync. Then we iterate “Camera” album contents with the method getAssetsAsync that acts as a cursor obtaining items batch by batch. Since the resulting items contain only basic information about the item we need to perform additional request to obtain geolocation. We do this inside populateLocationsIntoSet using getAssetInfoAsync method.

Displaying locations on a map

To display locations on a map I use react-native-maps.

const MainScreen = ({markers}: MainScreenProps) => {
    let map = useRef<MapView>(null);

    const fitAllMarkers = () => {
      const boundingBox = markers
      if (markers.length === 1) {
        markers.push({latitude: markers[0].latitude+0.1, longitude: markers[0].longitude+0.1})
      }
      map.current?.fitToCoordinates(boundingBox, {
        edgePadding: DEFAULT_PADDING,
        animated: true,
    })};

    return (
       <View style={styles.container}>
        <MapView ref={map}
          style={styles.map}
          onMapLoaded={fitAllMarkers}>
          {markers.map((item : MediaLibrary.Location) => (
            <Marker
              key={Math.random()}
              coordinate={{
                latitude: item.latitude,
                longitude: item.longitude,
              }}
              icon={require('../assets/marker.png')}>
            </Marker>
          ))}
        </MapView>
    );
  }
In the code above we use MapView component and for each item inside markers array we place a Marker on the map. Once the map is loaded we focus it on the area where the markers are set with fitToCoordinates method.

Sharing your map

I guess you can never imagine the modern world without people sharing stuff on social networks. So the crucial piece of functionality I wanted to embed in my application is “Share” button.

To share the results we’ll require the combination of react-native-view-shot that captures the state of our screen once the map is rendered and expo-sharing to share the screenshot we’ve captured.

const captureAndShareScreenshot = async () => {
  const uri = await captureRef(map, {
      format: "png",
      quality: 1
  })
  await Sharing.shareAsync("file://" + uri);
};

Checking for connection status

The downside of react native maps is that it requires internet connection to load the tiles. That’s why we would like to gracefully notify user if the internet connection is present instead of displaing broken maps.

In order to probe for internet connection we’ll require NetInfo.

Let’s revisit AnimatedAppLoader component.

const AnimatedAppLoader = () => {
  const [isConnected, setConnected] = useState(false)
  const [isConnectionProbeFinished, setConnectionProbeFinished] = useState(false)

  const fetchConnection = async () => {
    const connectionStaus = await NetInfo.fetch();
    setConnected(connectionStaus.isConnected === true)
    if (!connectionStaus.isConnected) {
      await SplashScreen.hideAsync();
    }
    setConnectionProbeFinished(true)
  }

  useEffect(() => {
     fetchConnection()
  }, [])

  const refresh = useCallback(async () => {
    await fetchConnection()
  },[])

  return (
      <View style={{ flex: 1 }}>
        {isConnected && isConnectionProbeFinished && <AnimatedSplashScreen/>}
        {!isConnected && isConnectionProbeFinished && <OfflineScreen refresh={refresh}/>}
      </View>
    )
  }

The point I’d like to highlight is the passing refresh inside the OfflineScreen component. Doing so allows us to perform another connection probe from inside the child component.

const OfflineScreen = ({refresh}: any) => {
    return (
        <View style={styles.container}>
            <Text style={styles.text}>
                Locate! needs an internet connection to function properly. {'\n'}
                Refresh this page once you enable it.
            </Text>
            <Button
                icon="refresh"
                mode="contained"
                buttonColor="#E81E25"
                textColor="#FFF"
                theme={CustomTheme}
                style={styles.button}
                onPress={() => refresh()}>
                    Refresh
            </Button>
        </View>
    )
}

Conclusion

Summarizing my journey I would say that React Native and Expo, in particular, are pretty convenient tools that allow you to develop mobile applications without the need to master Android/IOS specific technologies. With the use of the widely popular React framework, I’ve managed to develop the application I’ve wanted for myself for a pretty long time.

As per my personal experience publishing the application is a pretty tedious process and I’m not sure that I would like to try it again but that is the part I’ll omit in this article.