'Curved view shape in React Native

I wanna replicate the following layout, where there are 2 views (one with white background and the other one with a blue background).

enter image description here

The issue is, I can't replicate 100% the "curve" of the blue view shape.

Best I've got:

enter image description here

Simple component:

function App() {
  return (
    <View style={styles.background}>
      <View style={styles.primaryView}></View>
      <View style={styles.ovalSection}>
        <Text style={styles.forgotPasswordText}>Forgot Password?</Text>
      </View>
    </View>
  );
}

Stylesheet (check ovalSection class):

const width = Dimensions.get("screen").width;

const styles = StyleSheet.create({
  background: {
    backgroundColor: "#ffffff",
    flex: 1,
    flexDirection: "column"
  },
  primaryView: {
    backgroundColor: "#ffffff",
    flex: 1
  },
  ovalSection: {
    flex: 3,
    alignItems: "center",
    alignSelf: "center",
    backgroundColor: "#4646ff",
    borderTopLeftRadius: width * 0.7,
    borderTopRightRadius: width * 0.7,
    width: width * 2
  },
  forgotPasswordText: {
    fontSize: 14,
    color: "#ffffff",
    marginTop: 20,
    marginBottom: 20
  }
});

Any thoughts on how to make it properly?

I also wanna make sure that the style logic will make the shape stays the same, no matter the device or vertical / horizontal orientation.

Edit elegant-tree-lfweyd

Thank you!



Solution 1:[1]

First, note that the css property for border-{top/bottom}-{left/right}-radius can accept two numerical values. When two are supplied, they respectively specify the length of the horizontal and vertical semi-axes of an ellipse.

The ellipse will be laid out such that its edges are tangent to the top and side border of the element. Eg, if we specified border-top-left-radius: 25pt 55pt;, this would result in:

enter image description here

Given that, we can imagine that by using a very 'short and flat' ellipse on the top borders, we can get the shape you are looking for. But there are a few caveats:

  • In order to prevent any 'flat area' on top, the horizontal axis of the ellipse must be at least half the element width.
  • Since you don't want to see the side portion where it meets vertically with the edge, you have to make the element larger than the width of the page to hide that area.
  • The container needs to be tall enough to contain the whole ellipse, so you have to give it a min-height. By extension, this pushes the top section up if the screen does not have enough height to match. You may want to also enforce a min-height on the top section, or enable the overall container to scroll.

Finally, we can address what is meant by

make the shape stay the same

There are a few ways to interpret that, but one might be to make sure that the angle at which the screen intersects the ellipse is constant, no matter the width of the screen. To accomplish that, we would need to adjust the overall width of the ellipse until it meets the screen at the desired angle.

You can use trigenometry to figure out a formula that will calculate the ellipse width when given a target intersection angle. I have supplied such a formula below (and omitted its very long derivation for brevity).

Note, I made an error in the original formula. See here for the derivation of the correct formula.

const screenRadius = width / 2;

const horizontalAxisLength = (screenRadius / Math.SQRT2) * Math.sqrt(1 + Math.sqrt(1 + 4/Math.tan(theta)**2))

Where theta measures the angle at which the ellipse intercepts the vertical screen edge. A value of 90 degrees would produce a semi-circular border, while 0 will be a flat border. You will want to choose a value somewhere just above 0.

Note, convert degrees to radians when defining theta:

const degToRad = deg => deg*Math.PI/180;
const theta = degToRad(7);

Putting all this together, you might adjust your code like so:

const degToRad = (deg) => (deg * Math.PI) / 180;
const radToDeg = (rad) => (rad * 180) / Math.PI;

const width = Dimensions.get("window").width;
const screenRadius = width / 2;

// NOTE: Modify this theta value as needed!
const theta = degToRad(7);
const horizontalAxisLength = (screenRadius / Math.SQRT2) * Math.sqrt(1 + Math.sqrt(1 + 4/Math.tan(theta)**2))

const styles = StyleSheet.create({
  background: {
    backgroundColor: "#ffffff",
    flex: 1,
    flexDirection: "column"
  },
  primaryView: {
    backgroundColor: "#ffffff",
    flex: 1,
    minHeight: "200px", // NOTE: Compensates for ellipse min-height. Adjust as needed.
  },
  ovalSection: {
    flex: 3,
    alignItems: "center",
    alignSelf: "center",
    backgroundColor: "#4646ff",
    borderTopLeftRadius: `${horizontalAxisLength}px ${screenRadius}px`,
    borderTopRightRadius: `${horizontalAxisLength}px ${screenRadius}px`,
    width: horizontalAxisLength * 2,
    minHeight: `${2*screenRadius}px` // NOTE: IMPORTANT
  },
  forgotPasswordText: {
    fontSize: 14,
    color: "#ffffff",
    marginTop: 20,
    marginBottom: 20
  }
});

This seems to produce the result you are looking for, and works relatively well across a range of device widths. Note that you may have to recalculate these values whenever the device width is changed.

Here are those modifications in your codesandbox:

Edit relaxed-elbakyan-d9lwk0

Note that in the editor there, you need to refresh the preview section when you change the width, so that it recalculates the proper dimensions.


Full code:

import React from "react";
import { StyleSheet, Text, View, Dimensions } from "react-native";

function App() {
  return (
    <View style={styles.background}>
      <View style={styles.primaryView}></View>
      <View style={styles.ovalSection}>
        <Text style={styles.forgotPasswordText}>
          {width}, {horizontalAxisLength}
        </Text>
      </View>
    </View>
  );
}

const degToRad = (deg) => (deg * Math.PI) / 180;
const radToDeg = (rad) => (rad * 180) / Math.PI;

const width = Dimensions.get("window").width;
const screenRadius = width / 2;

const theta = degToRad(7);
const horizontalAxisLength = (screenRadius / Math.SQRT2) * Math.sqrt(1 + Math.sqrt(1 + 4/Math.tan(theta)**2))

const styles = StyleSheet.create({
  background: {
    backgroundColor: "#ffffff",
    flex: 1,
    flexDirection: "column"
  },
  primaryView: {
    backgroundColor: "#ffffff",
    flex: 1,
    minHeight: `200px`
  },
  ovalSection: {
    flex: 3,
    flexGrow: 3,
    alignItems: "center",
    alignSelf: "center",
    backgroundColor: "#4646ff",
    borderTopLeftRadius: `${horizontalAxisLength}px ${screenRadius}px`,
    borderTopRightRadius: `${horizontalAxisLength}px ${screenRadius}px`,
    width: horizontalAxisLength * 2,
    minHeight: `${2 * screenRadius}px`
  },
  forgotPasswordText: {
    fontSize: 14,
    color: "#ffffff",
    marginTop: 20,
    marginBottom: 20
  }
});

export default App;

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1