magnuskahr

writing code

Better placements of bottom buttons in SwiftUI

Many modern apps place a large button at the bottom of the screen, as a very easy call to action. Often, a such button will need to have padding at the bottom and horizontally for a proper visual look, but this can be quite a tricky thing to build in SwiftUI.

The framework offers the safeAreaInset modifier, that will place a view at any given edge of the applied view. For modern iPhones that means, if we apply a safeAreaInset to the bottom edge, the added view will be placed just above the home indicator - leaving a nice spacing at the bottom of the view. However, if we bring forth the keyboard this spacing is now gone, and - on older iPhones with a home button - the view will always be placed at the screen edge.

In the following, we will create a custom modifier that mimics safeAreaInsets but with the ability to add a minimum spacing to the screen edge. So, the overlay still will obey the safe area if it is larger than the minimum spacing.

Reading safe area

The idea behind this modifier is simple: read the safe area, place the view ignoring the safe area, and now add the view with custom padding. The custom padding we apply will be the maximum of either the safe area or a given minimum inset, except when the keyboard is showing, we just wanna use the minimum inset. Therefore, we need a way to read the safe area of both the container and the keyboard, to do so we have the following function:

private extension View {
	func readingBottomSafeArea(of region: SafeAreaRegions, into binding: Binding<Double>) -> some View {
		overlay {
			GeometryReader { proxy in
				let safeArea: Double = proxy.safeAreaInsets.bottom
				Color.clear
					.onAppear { binding.wrappedValue = safeArea }
					.onChange(of: safeArea) { binding.wrappedValue = $0 }
					.ignoresSafeArea(region, edges: .bottom)
			}
			.ignoresSafeArea(.all.subtracting(region), edges: .bottom)
		}
	}
}

This function adds an invisible overlay to our view and reads the bottom safe area of a region (container or keyboard) into a binding. If we use the modifier to read both regions, we can then calculate our custom padding:

private var safeArea: CGFloat {
	if keyboardSafeArea > containerSafeArea {
		return minimumBottomSpacing
	} else {
		return max(minimumBottomSpacing, containerSafeArea)
	}
}

This is a heuristic, but if the keyboard insets are larger than the container safe area, we assume the keyboard to be showing and return the minimum spacing. If not, we return the maximum of either the container safe area or the minimum spacing.

Assembling the pieces

Now we can create a new ViewModifier:

fileprivate struct BottomSafeAreaInsets<Overlay: View>: ViewModifier {

	let minimumBottomSpacing: CGFloat
	let topSpacing: CGFloat?
	let overlay: Overlay
	
	@State private var keyboardSafeArea = 0.0
	@State private var containerSafeArea = 0.0
	
	private var safeArea: CGFloat {
		if keyboardSafeArea > containerSafeArea {
			return minimumBottomSpacing
		} else {
			return max(minimumBottomSpacing, containerSafeArea)
		}
	}
	
	func body(content: Content) -> some View {
		content
			.safeAreaInset(edge: .bottom, spacing: topSpacing) {
				overlay.padding(.bottom, safeArea)
			}
			.ignoresSafeArea(.container, edges: .bottom)
			.readingBottomSafeArea(of: .container, into: $containerSafeArea)
			.readingBottomSafeArea(of: .keyboard, into: $keyboardSafeArea)
	}
}

It places an overlay using the SwiftUI safeAreaInset modifier and applies our custom safe area padding to the bottom. It then ignores any system container safe area, as we have just added our own. Finally - we read the system safe area of respectively the container and the keyboard - so we are able to calculate our own custom padding.

Finally - with an extension on View, we can use our new modifier easily:

extension View {
	func bottomSafeAreaInsets<Overlay: View>(minimumBottomSpacing: CGFloat, topSpacing: CGFloat? = nil, @ViewBuilder overlay: () -> Overlay) -> some View {
		modifier(BottomSafeAreaInsets(minimumBottomSpacing: minimumBottomSpacing, topSpacing: topSpacing, overlay: overlay()))
	}
}

Conclusion

We have created a new modifier that we can use to place views at the bottom of the screen, with a minimum spacing to the bottom edge, making it easy to create modern designs with a large call-to-action button.