DEV Community

Cover image for Responsive UX Design with React Native Reflect (Part 4)
Santiago Ferreira
Santiago Ferreira

Posted on

Responsive UX Design with React Native Reflect (Part 4)

Responsive UX Design with React Native Reflect (Part 4)

Description

The following tutorial explains step by step how to create a responsive photo album app with React Native and React Native Reflect that works on Web and Native devices.

Our photo album app will display images in a grid with variable number of columns, image aspect ratio, grid separation, etc. all in a responsive manner. We'll also create several responsive, theme-based UI components, including conditional rendering based on screen size.

This is a tutorial series, you need to complete Part 3 to be able to continue from here

Conditional Content

When there is no search selected, our UI looks quite empty on larger screens. Let's add a fixed full size image below the search buttons only on larger screens. On smaller screens we won't show the fixed image since the buttons are taking most of the screen space.

React Native Images require explicit width and height values. To take care of this, we'll create a new component (FullWidthImage) for rendering our full width image. FullWidthImage will determine it's width from it's parent component and it's height by dividing its width by an aspect ratio value.

src/FullWidthImage.tsx:

import React, { useState } from "react";
import { View, Image, LayoutChangeEvent } from "react-native";

type FullWidthImageProps = { uri: string; aspectRatio: number };

/**
 * Renders full width image by inferring its width from the Image's parent
 * component and its height using an aspect ratio value.
 */
export default function FullWidthImage({
  uri,
  aspectRatio = 1,
}: FullWidthImageProps) {
  const [width, setWidth] = useState(0);

  // set Image's width to the width of its parent component
  const onLayout = (obj: LayoutChangeEvent) =>
    setWidth(obj.nativeEvent.layout.width);

  return (
    <View onLayout={onLayout}>
      <Image style={{ width, height: width / aspectRatio }} source={{ uri }} />
    </View>
  );
}

Now, add the following lines to App.tsx, to use the new component we created and to add the logic to display our full width image only on larger screens.

import FullWidthImage from "./src/FullWidthImage";
const COVER_IMAGE_URI =
  "https://images-assets.nasa.gov/image/0700064/0700064~medium.jpg";

We can very easily create conditional rendered components using Reflect responsive attrs. Below, add attrs.displayHomeImg: [false, true] which will be false on smaller screens and true on larger screens.

  const { attrs, styles } = useStyled({
    ...
    attrs: {
      ...
      // only show cover image on small screens
      displayHomeImg: [false, true],
    },
  });

Then, let's use our newly created attrs.displayHomeImg value on our App's render() method.

return (
  <ThemeProvider value={theme}>
    <SafeAreaView>
      <Container>
        <SearchTerms onChange={createQuery} />
        {(() => {
          if (isLoading) return <MyActivityIndicator />;

          // display cover image only on larger screens when there is no
          // other data to display
          if (data.length === 0 && attrs.displayHomeImg)
            return (
              <FullWidthImage aspectRatio={16 / 9} uri={COVER_IMAGE_URI} />
            );

          return (
            <ImageGrid
              data={data}
              numColumns={attrs.numColumns}
              aspectRatio={attrs.imageAspectRatio}
              gridGap={styles.gridGap.margin as number}
            />
          );
        })()}
      </Container>
    </SafeAreaView>
  </ThemeProvider>
);

Your App should look like this:

Screens 08

Navigation Bar

Next, let's create a dummy navigation bar with a logo and some other elements. Add the following lines to App.tsx

import { Ionicons } from "@expo/vector-icons";
const NavBar = styled(View, {
  flexDirection: "row",
  alignItems: "center",
  justifyContent: "space-between",
  backgroundColor: "white",
  height: 7, // theme.sizes[7] = 64
  borderWidth: 0, // theme.borderWidths[0] = 1
  borderColor: "lightGray", // theme.colors["lightGray"] = "#EAEBEE"
  marginBottom: 4, // theme.space[4] = 16
  paddingLeft: 4, // etc.
  paddingRight: 5,
  shadowColor: "black",
  shadowOffset: { width: 0, height: 1 },
  shadowOpacity: 0.3,
  shadowRadius: 8,
  elevation: 1,
});
  return (
    <ThemeProvider value={theme}>
      <SafeAreaView>
        <NavBar>
          <Ionicons name="md-planet" size={32} color="black" />
          <Ionicons name="md-menu" size={32} color="black" />
        </NavBar>
        ...
    </ThemeProvider>

Screens 09

Notice that <NavBar> renders its shadows below and above itself which looks bad on some mobile devices because of the top spacing created by <SafeAreaView>. One way to fix this is by using react-native-safe-area-context instead of SafeAreaView.

To get native links working, install react-native-safe-area-context with expo:

expo install react-native-safe-area-context@latest

NOTE: react-native-safe-area-context was having some issues for me with Expo 37, after I upgraded my Expo app to 38.0.0, the installation worked just fine.

Then, replace the following lines in App.tsx

Remove SafeAreView, we'll use imports from react-native-safe-area-context insted:

import { View, ActivityIndicator } from "react-native";
...
import {
  SafeAreaProvider,
  useSafeAreaInsets,
} from "react-native-safe-area-context";

Create a more complex NavBar component that applies shadows to a Wrap wrapper component and compensates for the top inset spacing with an empty View component. Read the comments in the code below for more info:

const NavBar = ({ children }) => {
  const insets = useSafeAreaInsets();

  const Bar = styled(View, {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    backgroundColor: "white",
    // theme.sizes[7] = 64
    height: 7,
    // theme.borderWidths[0] = 1
    borderWidth: 0,
    // since theme.borderWidths[0] is 1, we can use "px" suffix to ignore the
    // theme. "0px" gets gets converted to 0 (number)
    borderTopWidth: "0px",
    // theme.colors["lightGray"] = "#EAEBEE"
    borderColor: "lightGray",
    paddingLeft: 4,
    paddingRight: 5,
  });

  const Wrap = styled(View, {
    // smaller screens: theme.space[2] = 4
    // larger  screens: theme.space[4] = 16
    marginBottom: [1, 4],
    shadowColor: "black",
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: [0.2, 0.3],
    shadowRadius: [3, 10],
    backgroundColor: "white",
    elevation: [0.2, 0.8],
  });

  return (
    <Wrap>
      <View style={{ height: insets.top }} />
      <Bar>{children}</Bar>
    </Wrap>
  );
};

Let's adjust some other style values to make our app look better:

const Container = styled(View, {
  // small  screens: 2 -> theme.space[2] = 4
  // medium screens: 7 -> theme.space[7] = 64
  // medium screens: 8 -> theme.space[9] = 128
  marginRight: [2, 7, 8],
  marginLeft: [2, 7, 8],
});
const SearchTermsWrap = styled(View, {
  paddingBottom: [0, 4],
});

Finally, let's update our main render() method:

return (
  <ThemeProvider value={theme}>
    <SafeAreaProvider>
      <NavBar>
        <Ionicons name="md-planet" size={32} color="black" />
        <Ionicons name="md-menu" size={32} color="black" />
      </NavBar>
      <Container>
        <SearchTermsWrap>
          <SearchTerms onChange={createQuery} />
        </SearchTermsWrap>
        ...
      </Container>
    </SafeAreaProvider>
  </ThemeProvider>
);

Your app should like like this:

Screens 10

Screens 11

The End

Hope you enjoyed working with Reflect. If you decide to use it in your projects, have questions or feedback, or just want to say hello, please write a comment below!

Links

Top comments (0)