DEV Community

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

Posted on

Responsive UX Design with React Native Reflect (Part 3)

Responsive UX Design with React Native Reflect (Part 3)

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 2 to be able to continue from here

Theming

On Part 2 of this tutorial series we finished building an image grid component with responsive number of columns, grid spacing and aspect ratio. On Part 3 we will add the option to query different images and create theme-based, responsive components using Reflect's styled() method.

Add the following lines to our App() component and look at the logged output.

import { useStyled, defaultTheme } from "react-native-reflect";
console.log(defaultTheme);

Notice the following properties of defaultTheme:

  • breakpoints: screen width dimensions at which responsive values change (an abbreviated way to define media queries).
  • sizes: theme values for width, height, etc.
  • space: theme values for padding, maring, etc.
  • You get an idea of what the other properties are for. A complete guide to Reflect's theme can be found here: Reflect / Theme

We'll create our own theme object by extending defaultTheme and use it with ThemeProvider to set a global theme for our application. Modify App.tsx as follows:

import { useStyled, defaultTheme, ThemeProvider } from "react-native-reflect";

const theme: Theme = {
  ...defaultTheme,
  colors: { lightGray: "#EAEBEE", highlight: "#E9F0FE" },
  space: [0, 2, 4, 8, 16, 20, 32, 64, 128, 256],
  sizes: [0, 2, 4, 8, 16, 20, 32, 64, 128, 256],
  radii: [0, 15, 30],
};

Finally, wrap the return value of App() with <ThemeProvider value={theme}:

return (
  <ThemeProvider value={theme}>
    ...
  </ThemeProvider>
);

Now, we'll be able to access our theme from Reflect's styled() or useStyled() methods. For example if we create a component using const Container = styled(View, { padding: 2}), the value 2 of padding will be interpreted as as an index of theme.space array, as follows:theme.space[2] which is equal to 4.

Extending App's Functionality and UX

So far our app is displaying images based on a fixed query. Let's extend it by providing various predefined search queries. The new search queries will be rendered as buttons, once we tap on a button, the search query will update, the images will be rendered, and the rest of the buttons will hide. After we tap the active button again, the search query will clear and all the other buttons will show again.

This is how our app will look after we add the search terms buttons:

On Web:

On Mobile:

As you can see from the screen recordings above, we'll also make our buttons layout responsive. They will display as single full width rows (flexDirection: "columns") on smaller screens and as wrapped boxes on larger screens (flexDirection: "row", flexWrap: "wrap")

To style these new components we'll use Reflect's styled() function. Let's get started!

Create a new file: src/SearchTerms.tsx, add the following lines to it, and follow the comments in the code for an explantion of the concepts and methods used.

Container is the simplest component we are creating using Reflect's styled().

Button is a more complex component, it takes an active prop which changes it's color, an onPress callback and a title. When creating more complex components with styled(), just wrap it with a functional component and add all the necessary logic, composition, etc. there.

src/SearchTerms.tsx:

import React, { useState, useEffect } from "react";
import _ from "lodash";
import { Text, View, TouchableOpacity } from "react-native";
import { styled } from "react-native-reflect";

const SEARCH_TERMS = [
  "Milky Way",
  "Andromeda",
  "Antennae Galaxies",
  "Black Eye Galaxy",
  "Butterfly Galaxies",
  "Cartwheel Galaxy",
  "Fireworks Galaxy",
  "Sombrero Galaxy",
  "Cigar Galaxy",
  "Sculptor Galaxy",
  "Sunflower Galaxy",
];

type OnPress = () => void;
type ButtonProps = { title: string; onPress: OnPress; active: boolean };
type SearchTermsProps = { onChange: (term: string) => void };

/**
 * Renders search terms buttons as follows:
 * - smaller screens: full width columns (one search term per column)
 * - larger  screens: wrapped rows (search termns next to each other in a row)
 */
const Container = styled(View, {
  // flex: 1,
  // themed value -> 3 -> theme.space[3] = 8
  marginTop: 3,
  // "column" on smaller screens, "row" on larger screens
  flexDirection: ["column", "row"],
  // "nowrap" on smaller screens, "wrap" on larger screens
  flexWrap: ["nowrap", "wrap"],
});

/**
 * Renders single search term item as a styled TouchableOpacity component.
 *
 * Button style values are responsive and theme-based, look at
 * comments below for more info
 */
const Button = ({ title, onPress, active }: ButtonProps) => {
  const Styled = styled(TouchableOpacity, {
    // themed value -> 5 -> theme.space[5] = 20
    padding: 5,
    // smaller screens: 0 -> no marginRight, since button will be full width
    // larger  screens: themed value -> 3 -> theme.space[3] = 8
    marginRight: [0, 3],
    marginBottom: 3,
    borderRadius: 1,
    borderWidth: 0,
    borderColor: "lightGray",
    backgroundColor: active ? "highlight" : undefined,
  });

  return (
    <Styled onPress={onPress}>
      <Text>{title}</Text>
    </Styled>
  );
};

/**
 * Renders search terms as a list of buttons.
 * - Tapping on a button, selects it and hides all other buttons
 * - Tapping on a selected button, de-selects it and shows all other buttons
 * - onChange(term) gets called on term selection updates with the updated term
 */
export default function SearchTerms({ onChange }: SearchTermsProps) {
  const [selected, setSelected] = useState(-1); // index of selected search term

  const onPress = (index: number) => {
    if (selected > -1) return setSelected(-1); // reset selection
    setSelected(index); // set selection
  };

  useEffect(() => {
    // onChange is called with the selected term or "" if no term is selected
    onChange(selected < 0 ? "" : SEARCH_TERMS[selected]);
  }, [selected]);

  // <  0 will render all search terms
  // >= 0 will render only selected term
  const renderData = selected < 0 ? SEARCH_TERMS : [SEARCH_TERMS[selected]];

  return (
    <Container>
      {_.map(renderData, (title, index) => (
        <Button
          title={title}
          onPress={() => onPress(index)}
          active={selected > -1}
          key={index}
        />
      ))}
    </Container>
  );
}

Now, replace the contents of App.tsx with the following. Again, following the comments in the code for the necessary explanations.

App.tsx:

import React, { useEffect, useState } from "react";
import { View, SafeAreaView, ActivityIndicator } from "react-native";
import Axios from "axios";
import {
  styled,
  useStyled,
  defaultTheme,
  ThemeProvider,
  Theme,
} from "react-native-reflect";

import ImageGrid from "./src/ImageGrid";
import SearchTerms from "./src/SearchTerms";

const theme: Theme = {
  ...defaultTheme,
  colors: { lightGray: "#EAEBEE", highlight: "#E9F0FE" },
  space: [0, 2, 4, 8, 16, 20, 32, 64, 128, 256],
  sizes: [0, 2, 4, 8, 16, 20, 32, 64, 128, 256],
  radii: [0, 15, 30],
};

// Items used by ImageGrid, contains list of images.
type Items = { links: [{ href: string }] }[];

// Data returned by HTTP request
type AxiosData = {
  collection: {
    items: Items;
  };
};

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

// marginTop: 7 = theme.space[7] = 64
const MyActivityIndicator = styled(ActivityIndicator, { marginTop: 7 });

export default function App() {
  const [isLoading, setLoading] = useState(false);
  const [data, setData] = useState<Items>([]);
  const [query, setQuery] = useState("");

  // Create and set search query using terms argument
  const createQuery = (terms: string) => {
    if (!terms) return setQuery("");

    const encodeTerms = terms.replace(/\s/g, "%20");
    setQuery(
      `https://images-api.nasa.gov/search?q=${encodeTerms}&media_type=image`
    );
  };

  // Get our data
  useEffect(() => {
    if (!query) {
      setData([]);
      setLoading(false);
      return;
    }

    setLoading(true);
    Axios.get<AxiosData>(query)
      .then(({ data }) => {
        setData(data.collection.items);
      })
      .catch((error) => console.error(error))
      .finally(() => setLoading(false));
  }, [query]);

  // Responsive values
  const { attrs, styles } = useStyled({
    styles: {
      // small  screens: 2 -> theme.space[2] = 4
      // medium screens: 3 -> theme.space[7] = 8
      // medium screens: 4 -> theme.space[9] = 16
      gridGap: { margin: [2, 3, 4] },
    },
    attrs: {
      // 1 on small screens, 3 on medium screens, 4 on large screens
      numColumns: [1, 3, 4],
      // 4/3 on small screens, 1 on medium and large screens
      imageAspectRatio: [4 / 3, 1],
    },
  });

  // After loading is done "isLoading", we render our images using <ImageGrid/>
  return (
    <ThemeProvider value={theme}>
      <SafeAreaView>
        <Container>
          <SearchTerms onChange={createQuery} />
          {isLoading ? (
            <MyActivityIndicator />
          ) : (
            <ImageGrid
              data={data}
              numColumns={attrs.numColumns}
              aspectRatio={attrs.imageAspectRatio}
              gridGap={styles.gridGap.margin as number}
            />
          )}
        </Container>
      </SafeAreaView>
    </ThemeProvider>
  );
}

Launch your application on a native device (or simulator) and on a web browser. The app you should look like the screen recordings above.

That's all for Part 3! in this section we defined a global theme object for our application and created several components with styled() that derive their styling values from the theme. We also creatd different layouts for smaller and larger screens, including conditional content that only gets rendered on larger screens.

Next Steps

On Part 4, we will finish creating our UI, we'll add a navigation bar, a better layout and improve the overall design of our app.

Links

Top comments (0)