Implementing Style: .RectangularBackground in SwiftUI

Brian Masse
6 min readJan 13, 2024

Rounded Rectangular backgrounds are becoming increasingly popular in UI and UX design across all platforms as a powerful way to distinguish pieces of content, signal an interactable component, or simply to add a quick bit of style to an otherwise simple interface. After around 5 apps rewriting the same rectangularBackground modifier in swift I’ve compiled the features I’ve used most frequently into one easily invoked and customizable SwiftUI viewModifier.

You can find the completed example in this repo.

Foundational Components

The actual viewModifier is quite simple, however a few additional features and UI systems makes the rectangularBackground far more useful and versatile.

Conditional Modifier

Depending on the value of certain args, we may want to change the style of the rectangularBackground. To accomplish this we want a conditionalModifier system. I have already written an article going into depth on this topic, so I will just show the final solution here.

extension View {
@ViewBuilder
func `if`<Content: View>( _ condition: Bool, contentBuilder: (Self) -> Content ) -> some View {
if condition {
contentBuilder(self)
} else { self }
}
}

Universal Style

Most apps have a clear and simple color palette, containing, at their core, a base color, a secondary color, and an accent color. Ideally instead of manually plugging in those color values into certain views, such as .rectangularBackground, you could just specify the style and have the UI determine what color it should be. This becomes especially important when you have different colors for different colorSchemes, or you want to be able dynamically change the color palette in the app. UniversalStyle solves this problem

public enum UniversalStyle: String, Identifiable {
case accent
case primary
case secondary
case transparent

public var id: String {
self.rawValue
}
}

These 4 cases are the default styles that I use in my apps, but you can expand the enum to include more or less options.

Colors

Now that we have a way to invoke styles, we need to actually store the SwiftUI Colors. I keep all my colors as static properties in a Colors class.

public class Colors {
public static var baseLight = makeColor( 245, 234, 208 )
public static var baseDark = makeColor( 0,0,0 )

public static var secondaryLight = makeColor(220, 207, 188)
public static var secondaryDark = Color(red: 0.1, green: 0.1, blue: 0.1).opacity(0.9)

public static var lightAccent = makeColor( 0, 87, 66)
public static var darkAccent = makeColor( 0, 87, 66)

///the makeColor function takes a red, green, and blue argument and returns a SwiftUI Color. All values are from 0 to 255. This function is entirely for convenience and to avoid using the built in rgb initializer on Color.
public static func makeColor( _ r: CGFloat, _ g: CGFloat, _ b: CGFloat ) -> Color {
Color(red: r / 255, green: g / 255, blue: b / 255)
}
}

Now with the color values and style cases defined, we have to link them. I’ve done this with static functions on the Colors class.

private static func getAccent(from colorScheme: ColorScheme) -> Color {
switch colorScheme {
case .light: return Colors.lightAccent
case .dark: return Colors.darkAccent
@unknown default:
return Colors.lightAccent
}
}

private static func getBase(from colorScheme: ColorScheme) -> Color {
switch colorScheme {
case .light: return Colors.baseLight
case .dark: return Colors.baseDark
@unknown default:
return Colors.baseDark
}
}

private static func getSecondaryBase(from colorScheme: ColorScheme) -> Color {
switch colorScheme {
case .light: return Colors.secondaryLight
case .dark: return Colors.secondaryDark
@unknown default:
return Colors.secondaryDark
}
}

public static func getColor(from style: UniversalStyle, in colorScheme: ColorScheme) -> Color {
switch style {
case .primary: return getBase(from: colorScheme)
case .secondary: return getSecondaryBase(from: colorScheme)
case .accent: return getAccent(from: colorScheme)
default: return Colors.lightAccent
}
}

These functions are really all identical to each other. They take an iOS colorScheme and return the corresponding color. The final function is the most useful as it takes a specific style and returns the correct color for the active colorScheme.

universalStyledBackground

The final piece before we can get into the actual .rectangularBackground modifier is a super basic system that can use all the color logic we just coded. I’ve implemented this in a modifier that takes in a UniversalStyle or a color, and applies it to either a view’s background or foreground.

private struct UniversalStyledBackground: ViewModifier {
@Environment(\.colorScheme) var colorScheme

let style: UniversalStyle
let color: Color?
let foregrond: Bool

func body(content: Content) -> some View {
if !foregrond {
content
.if( style == .transparent ) { view in view.background( .ultraThinMaterial ) }
.if( style != .transparent ) { view in view.background( color ?? Colors.getColor(from: style, in: colorScheme) ) }
} else {
content
.if( style == .transparent ) { view in view.foregroundStyle( .ultraThinMaterial ) }
.if( style != .transparent ) { view in view.foregroundStyle( color ?? Colors.getColor(from: style, in: colorScheme) ) }
}
}
}

extension View {
func universalStyledBackgrond( _ style: UniversalStyle, color: Color? = nil, onForeground: Bool = false ) -> some View {
modifier( UniversalStyledBackground(style: style, color: color, foregrond: onForeground) )
}
}

.rectangularBackground

Now we can start building out the actual viewModifier. The first step is to simply apply that new background modifier with some flexible padding

private struct RectangularBackground: ViewModifier {

@Environment(\.colorScheme) var colorScheme

let style: UniversalStyle
let color: Color?

let padding: CGFloat?

func body(content: Content) -> some View {
content
.if(padding == nil) { view in view.padding() }
.if(padding != nil) { view in view.padding(padding!) }
.universalStyledBackgrond(style, color: color)
}
}

we use the conditional modifier to only apply the custom padding if the user provided a value, and otherwise use Apple’s default implementation of padding if they didn’t.

Next we’ll add a cornerRadius system. Ideally we’d like this to be a custom shape so users can set uneven corner radii on the modifier.

private struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners

func path(in rect: CGRect) -> Path {
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
return Path(path.cgPath)
}
}

public extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape( RoundedCorner(radius: radius, corners: corners) )
}
}

This solution is also great because it works on any type of view in swiftUI.

Next I’d like to be able to apply a shadow to offer the ability to easily construct and distinguish a view hierarchy. In my personal project I’ve only ever used a subtle black shadow, so I haven't made color or opacity an arg, but you could easily add that to the viewModifier struct if you wanted to dynamically change those properties. Again we will use the conditional modifier system to apply this shadow.

.if(shadow) { view in
view
.shadow(color: .black.opacity(0.2),
radius: 10,
y: 5)
}

Finally, I would like to be able to add a stroke. We’ll accomplish this by overlaying a stroked rectangle on top of the background. This is another reason to create a custom RoundedCorner shape, since it guarantees the that shape of the stroke matches the shape of the background. In this implementation I pass in a stroke:Bool and a strokeWidth:CGFloat, but you could just as easily pass in a stroke style or color if you wanted to customize those as well.

.if(stroke) { view in
view
.overlay(
RoundedCorner(radius: cornerRadius, corners: corners)
.stroke(colorScheme == .dark ? .white : .black, lineWidth: strokeWidth)
)
}

Now we just want to create a View extension to easily invoke this modifier throuhgout our app. This is a great place to set default values for the perametres you’ve set up in the struct so you don’t have to fill them out every time you use the modifier. For example, I’ve default shadow and stroke off, since I often just want a .primary styled rounded backgorund.

public extension View {
func rectangularBackground(_ padding: CGFloat? = nil,
style: UniversalStyle = .primary,
color: Color? = nil,
stroke: Bool = false,
strokeWidth: CGFloat = 1,
cornerRadius: CGFloat = 40,
corners: UIRectCorner = .allCorners,
shadow: Bool = false) -> some View {

modifier(RectangularBackground(style: style,
color: color,
padding: padding,
cornerRadius: cornerRadius,
corners: corners,
stroke: stroke,
strokeWidth: strokeWidth,
shadow: shadow))
}
}
private struct RectangularBackground: ViewModifier {

@Environment(\.colorScheme) var colorScheme

let style: UniversalStyle
let color: Color?

let padding: CGFloat?
let cornerRadius: CGFloat
var corners: UIRectCorner
let stroke: Bool
let strokeWidth: CGFloat
let shadow: Bool

func body(content: Content) -> some View {
content
.if(padding == nil) { view in view.padding() }
.if(padding != nil) { view in view.padding(padding!) }
.universalStyledBackgrond(style, color: color)
.if(stroke) { view in
view
.overlay(
RoundedCorner(radius: cornerRadius, corners: corners)
.stroke(colorScheme == .dark ? .white : .black, lineWidth: strokeWidth)
)
}
.cornerRadius(cornerRadius, corners: corners)
.if(shadow) { view in
view
.shadow(color: .black.opacity(0.2),
radius: 10,
y: 5)
}
}
}

Conclusion

All together we have a relatively simple, but incredibly versatile and powerful viewModifier that can enhance almost any layout or view. Of course, there are many additional ways to customize the modifier, from additional args to preset styles, so I encourage you to experiment with custom styling in your projects.

Thank you for reading my article. If you liked it, feel free to cheers it and check out my other work here. You can also follow my socials below.

--

--