Runtime layout switching

Runtime Layout Switching is basically switching the participant’s layout from the app. We currently support switching between grid and spotlight layout modes through our SDK.

Participant Layout Grid

Participant Layout Spotlight

Switching the layout from the App

To switch the layout from the app we can take the help of the layout prop of the CallContent component.

We will create a state variable in the app to track the state of the current layout and pass the state to the layout prop of the CallContent component. This is done below:

import React, { useState } from "react";
import { Call, CallContent } from "@stream-io/video-react-native-sdk";
import { StyleSheet, View } from "react-native";

const VideoCallUI = () => {
  const { selectedLayout } = useLayout();
  let call: Call;
  // your logic to create a new call or get an existing call

  return (
    <StreamCall call={call}>
      <CallContent layout={selectedLayout} />
    </StreamCall>
  );
};

Creating the CallHeader with Layout switching Modal/Component

We will create a component that renders the Button which on press opens up a Modal to switch the Layout. Clicking on the layout item will switch the layout and set the state for the selectedLayout state in the VideoCallUI component that we created above.

Preview of the LayoutSwitcherButton and LayoutSwitcherModal

import React, { useState } from "react";
import {
  CallControlsButton,
  useTheme,
} from "@stream-io/video-react-native-sdk";
import { IconWrapper } from "@stream-io/video-react-native-sdk/src/icons";
import LayoutSwitcherModal from "./LayoutSwitcherModal";
import { ColorValue } from "react-native";
import { Grid } from "../../assets/Grid";
import { SpotLight } from "../../assets/Spotlight";
import { useLayout } from "../../contexts/LayoutContext";

export type LayoutSwitcherButtonProps = {
  /**
   * Handler to be called when the layout switcher button is pressed.
   * @returns void
   */
  onPressHandler?: () => void;
};

const getIcon = (selectedButton: string, color: ColorValue, size: number) => {
  switch (selectedButton) {
    case "grid":
      return <Grid color={color} size={size} />;
    case "spotlight":
      return <SpotLight color={color} size={size} />;
    default:
      return "grid";
  }
};

/**
 * The layout switcher Button can be used to switch different layout arrangements
 * of the call participants.
 */
export const LayoutSwitcherButton = ({
  onPressHandler,
}: LayoutSwitcherButtonProps) => {
  const {
    theme: { colors, variants },
  } = useTheme();

  const { selectedLayout } = useLayout();
  const [isModalVisible, setIsModalVisible] = useState(false);
  const [anchorPosition, setAnchorPosition] = useState<{
    x: number;
    y: number;
    width: number;
    height: number;
  } | null>(null);

  const buttonColor = isModalVisible
    ? colors.iconSecondary
    : colors.iconPrimary;

  const handleOpenModal = () => setIsModalVisible(true);
  const handleCloseModal = () => setIsModalVisible(false);

  const handleLayout = (event: any) => {
    const { x, y, width, height } = event.nativeEvent.layout;
    setAnchorPosition({ x, y: y + height, width, height });
  };

  return (
    <CallControlsButton
      size={variants.roundButtonSizes.md}
      onLayout={handleLayout}
      onPress={() => {
        handleOpenModal();
        if (onPressHandler) {
          onPressHandler();
        }
        setIsModalVisible(!isModalVisible);
      }}
      color={colors.sheetPrimary}
    >
      <IconWrapper>
        {getIcon(selectedLayout, buttonColor, variants.iconSizes.lg)}
      </IconWrapper>
      <LayoutSwitcherModal
        isVisible={isModalVisible}
        anchorPosition={anchorPosition}
        onClose={handleCloseModal}
      />
    </CallControlsButton>
  );
};
import React, { useEffect, useState, useMemo } from "react";
import {
  View,
  TouchableOpacity,
  Text,
  StyleSheet,
  Dimensions,
  Modal,
} from "react-native";
import { useTheme } from "@stream-io/video-react-native-sdk";
import { Grid } from "../../assets/Grid";
import { SpotLight } from "../../assets/Spotlight";
import { Layout, useLayout } from "../../contexts/LayoutContext";

interface AnchorPosition {
  x: number;
  y: number;
  height: number;
}

interface PopupComponentProps {
  anchorPosition?: AnchorPosition | null;
  isVisible: boolean;
  onClose: () => void;
}

const LayoutSwitcherModal: React.FC<PopupComponentProps> = ({
  isVisible,
  onClose,
  anchorPosition,
}) => {
  const { theme } = useTheme();
  const styles = useStyles();
  const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
  const { selectedLayout, onLayoutSelection } = useLayout();
  const topInset = theme.variants.insets.top;
  const leftInset = theme.variants.insets.left;

  useEffect(() => {
    if (isVisible && anchorPosition) {
      const windowHeight = Dimensions.get("window").height;
      const windowWidth = Dimensions.get("window").width;
      let top = anchorPosition.y + anchorPosition.height / 2 + topInset;
      let left = anchorPosition.x + leftInset;

      // Ensure the popup stays within the screen bounds
      if (top + 150 > windowHeight) {
        top = anchorPosition.y - 150;
      }
      if (left + 200 > windowWidth) {
        left = windowWidth - 200;
      }

      setPopupPosition({ top, left });
    }
  }, [isVisible, anchorPosition, topInset, leftInset]);

  if (!isVisible || !anchorPosition) {
    return null;
  }

  const onPressHandler = (layout: Layout) => {
    onLayoutSelection(layout);
    onClose();
  };

  return (
    <Modal
      transparent
      visible={isVisible}
      onRequestClose={onClose}
      supportedOrientations={["portrait", "landscape"]}
    >
      <TouchableOpacity style={styles.overlay} onPress={onClose}>
        <View
          style={[
            styles.modal,
            { top: popupPosition.top, left: popupPosition.left },
          ]}
        >
          <TouchableOpacity
            style={[
              styles.button,
              selectedLayout === "grid" && styles.selectedButton,
            ]}
            onPress={() => onPressHandler("grid")}
          >
            <Grid
              size={theme.variants.iconSizes.md}
              color={theme.colors.iconPrimary}
            />
            <Text style={styles.buttonText}>Grid</Text>
          </TouchableOpacity>
          <TouchableOpacity
            style={[
              styles.button,
              selectedLayout === "spotlight" && styles.selectedButton,
            ]}
            onPress={() => onPressHandler("spotlight")}
          >
            <SpotLight
              size={theme.variants.iconSizes.md}
              color={theme.colors.iconPrimary}
            />
            <Text style={styles.buttonText}>Spotlight</Text>
          </TouchableOpacity>
        </View>
      </TouchableOpacity>
    </Modal>
  );
};

// styles omitted for brevity

export default LayoutSwitcherModal;

Layout context

We can create a context to manage the layout state and pass it to the LayoutSwitcherButton and LayoutSwitcherModal components.

import React, {
  createContext,
  useContext,
  useState,
  ReactNode,
  useCallback,
} from "react";

export type Layout = "grid" | "spotlight";

interface LayoutContextState {
  selectedLayout: Layout;
  onLayoutSelection: (layout: Layout) => void;
}

const LayoutContext = createContext<LayoutContextState | null>(null);

interface LayoutProviderProps {
  children: ReactNode;
}

const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
  const [selectedLayout, setSelectedLayout] = useState<Layout>("grid");

  const onLayoutSelection = useCallback((layout: Layout) => {
    setSelectedLayout(layout);
  }, []);

  return (
    <LayoutContext.Provider value={{ selectedLayout, onLayoutSelection }}>
      {children}
    </LayoutContext.Provider>
  );
};

const useLayout = (): LayoutContextState => {
  const context = useContext(LayoutContext);
  if (!context) {
    throw new Error("useLayout must be used within a LayoutProvider");
  }
  return context;
};

export { LayoutProvider, useLayout };

Creating a custom CallHeader

We will create a custom CallHeader component that renders the LayoutSwitcherButton as follows.

We need to make sure the props selectedLayout and setSelectedLayout passed from the component that renders the CallContent.

export const CustomCallHeader = () => {
  const [topControlsHeight, setTopControlsHeight] = useState<number>(0);
  const [topControlsWidth, setTopControlsWidth] = useState<number>(0);
  const styles = useStyles();

  const onLayout: React.ComponentProps<typeof View>["onLayout"] = (event) => {
    const { height, width } = event.nativeEvent.layout;
    if (setTopControlsHeight) {
      setTopControlsHeight(height);
      setTopControlsWidth(width);
    }
  };

  return (
    <View>
      <TopViewBackground height={topControlsHeight} width={topControlsWidth} />
      <View style={styles.content} onLayout={onLayout}>
        <View style={styles.centerElement}>
          <LayoutSwitcherButton />
        </View>
      </View>
    </View>
  );
};

Finally we use the CustomCallHeader together with the CallContent component as follows:

import React, { useCallback, useState } from "react";
import { Call, CallContent } from "@stream-io/video-react-native-sdk";

const VideoCallUI = () => {
  const { selectedLayout } = useLayout();
  let call: Call;
  // your logic to create a new call or get an existing call

  return (
    <StreamCall call={call}>
      <CustomCallHeader />
      <CallContent layout={selectedLayout} />
    </StreamCall>
  );
};
© Getstream.io, Inc. All Rights Reserved.