In the Example Series, we engineer solutions to custom UI/UX
systems and components, focusing on production quality code.
- Create a
Drawerby providing a content view and handle view.
struct ExampleView: View {
var body: some View {
Drawer {
Text("Content!")
} handle { position in
CustomHandle(position: position)
}
}
}- If the handle doesn't depend on drawer placement, use the initializer that takes a "nullary" closure:
struct ExampleView: View {
var body: some View {
Drawer {
Text("Content!")
} handle {
CustomHandle()
}
}
}- Configure the drawer's' attributes using the custom view modifiers:
.cornerRadius(_:),.panelStyle(_:),.placement(_:),.detents(_:),.interactiveDetents(_:).
struct ExampleView: View {
var body: some View {
Drawer {
...
}
.cornerRadius(12)
.placement(.bottom)
.panelStyle(.regularMaterial)
.detents([.medium, .large])
.interactiveDetents([.large])
}
}Try adding the Source directory to your project to use the Drawer in your app!
Code Design
When building applications, it's often tempting to create elements tailored strictly for the task at hand. However, if you've worked on a project long enough, you'll soon realize that the ability to make quick adjustments and alterations to components is just as crucial as developing them in the first place. The Drawer is designed with this flexibility in mind, allowing developers to tweak, repurpose, and visually adjust it without extensive refactoring. Below is a breakdown of the code structure and the rationale behind it.
Before delving into the specifics of the Drawer itself, let's explore the auxiliary components that define its behavior and functionality: the Detent type, the DrawerPosition enum, and the Buildable protocol.
A Detent represents a resting position for the drawer, defining where it will settle once released. This is achieved by specifying a value attribute, which is a decimal representing a percentage of the available space. For example, a Detent with a value of 0.5 means the drawer will cover 50% of the available space when at rest.
We opted for a percentage-based representation because it avoids the complications that can arise from explicit sizing. Using pixels or points to specify a Detent ties it to a fixed value, regardless of context. For instance, a height of 200 px/pts might work well in portrait mode but not in landscape. A percentage-based height, however, adapts to different contexts, making it more versatile. This approach also aligns with SwiftUI's preference for implicit layouts.
When defining and providing Detent objects to the Drawer, ease and convenience are key. A traditional initializer can be cumbersome for creating an object with a single value, so Detent conforms to ExpressibleByFloatLiteral. This conformance allows for creating Detent types from a raw Float value and sanitizing any unreasonable values via the required initializer.
struct Detent: ExpressibleByFloatLiteral {
...
let value: CGFloat
init(floatLiteral value: CGFloat.NativeType) {
...
self.value = min(max(value, lower), upper)
}
}To simplify the creation and use of detents further, we expose static values for common decimals:
struct Detent: ExpressibleByFloatLiteral {
static let all: ClosedRange<Detent> = 0.0 ... 1.0
static let hidden: Detent = 0.0
static let large: Detent = 1.0
static let medium: Detent = 0.5
static let small: Detent = 0.05
let value: CGFloat
init(floatLiteral value: CGFloat.NativeType) {
self.value = min(max(value, 0.0), 1.0)
}
}The value of a detent is automatically clamped to
Detent.all. Depending on the application, these values can be adjusted to allow or disallow different detents. Since theDrawercomputes its position in terms of detents, its behavior will automatically adjust based on these values.
The next component, DrawerPosition, is an enum that defines the possible orientations of the drawer within its container:
enum DrawerPosition {
case bottom
case leading
case top
case trailing
}While a bottom-placed drawer is standard and often sufficient, there are scenarios where a top-oriented or side-oriented drawer may be required. This enum allows for flexible positioning, supporting various use cases beyond the traditional drawer configuration.
The Buildable protocol defines a contract for setting object values using key paths and returning the updated object. It provides a default implementation that creates a mutable version of self, sets the key path value, and returns the updated object.
protocol Buildable {
func set<Value>(
_ keyPath: WritableKeyPath<Self, Value>,
to value: Value
) -> Self
}
extension Buildable {
func set<Value>(
_ keyPath: WritableKeyPath<Self, Value>,
to value: Value
) -> Self {
var newSelf = self
newSelf[keyPath: keyPath] = value
return newSelf
}
}This protocol allows conforming types to be configured through method chaining, enabling a SwiftUI.View-like configuration syntax for the Drawer and other custom objects.
Now that we understand the auxiliary types, let's introduce the Drawer API. The default initializer is for rendering a drawer with custom content and a handle that depends on the DrawerPosition value.
struct Drawer<Content: View, Handle: View>: View {
let content: Content
let handle: (DrawerPosition) -> Handle
...
init(
@ViewBuilder content: () -> Content,
@ViewBuilder handle: @escaping (DrawerPosition) -> Handle
) {
self.content = content()
self.handle = handle
}
...
}The Drawer also provides two convenience initializers: one for rendering custom content with a handle that isn't dependent on DrawerPosition, and one for using a default handle:
extension Drawer where Handle: View {
init(
@ViewBuilder content: () -> Content,
@ViewBuilder handle: @escaping () -> Handle
) {
self.init(content: content, handle: { _ in handle() })
}
}
extension Drawer where Handle == DefaultHandle {
init (@ViewBuilder content: () -> Content) {
self.init(content: content, handle: DefaultHandle.init)
}
}To support method chaining configuration, the Drawer conforms to the Buildable protocol and provides custom modifiers:
struct Drawer<Content: View, Handle: View>: View {
...
private var allDetents: Set<Detent> = []
private var interactiveDetents: Set<Detent> = []
private var cornerRadius: CGFloat = .zero
private var panelStyle: AnyShapeStyle = .init(.white)
private var position: DrawerPosition = .bottom
...
}
extension Drawer: Buildable {
func cornerRadius(_ radius: CGFloat) -> Self {
set(\.cornerRadius, to: radius)
}
func detents(_ detents: [Detent]) -> Self {
set(\.allDetents, to: Set(detents))
}
func interactiveDetents(_ detents: [Detent]) -> Self {
set(\.interactiveDetents, to: Set(detents))
}
func panelStyle<S: ShapeStyle>(_ style: S) -> Self {
set(\.panelStyle, to: AnyShapeStyle(style))
}
func position(_ position: DrawerPosition) -> Self {
set(\.position, to: position)
}
}It's important to call these custom modifiers before any standard library view modifiers, as the latter perform type erasure on the returned view.
The Drawer body consists of a switch statement that renders the appropriate panel based on the current position:
struct Drawer<Content: View, Handle: View>: View {
...
private var position = DrawerPosition.bottom
...
var body: some View {
GeometryReader { geometry in
switch position {
case .bottom:
_Panel(content: content, handle: handle(.bottom))
...
case .leading:
_Panel(content: content, handle: handle(.leading))
...
case .top:
_Panel(content: content, handle: handle(.top))
...
case .trailing:
_Panel(content: content, handle: handle(.trailing))
...
}
}
}
}By abstracting panel behavior into a private member, we encapsulate the transition between DrawerPosition values if it changes at runtime. This approach allows the drawer to gracefully manage transitions between different positions.
struct Drawer<Content: View, Handle: View>: View {
...
private var placement = DrawerPlacement.bottom
...
var body: some View {
GeometryReader { geometry in
switch placement {
case .bottom:
_Panel(content: content, handle: handle(.bottom))
...
.transition(.move(edge: .bottom))
case .leading:
_Panel(content: content, handle: handle(.leading))
...
.transition(.move(edge: .leading))
case .top:
_Panel(content: content, handle: handle(.top))
...
.transition(.move(edge: .top))
case .trailing:
_Panel(content: content, handle: handle(.trailing))
...
.transition(.move(edge: .trailing))
}
}
}
}This universal handling of position changes ensures consistent behavior across different drawer orientations.
At the core of the Drawer implementation is the _Panel view component. It uses a ZStack to layer the drawer panel, content, and handle from bottom to top. The layout logic is encapsulated in a DrawerLayout object, which computes view traits:
private struct _Panel<Content: View, Handle: View>: View {
let content: Content
let handle: Handle
private let gestureSpace = "drawer_space"
...
var cornerRadius: CGFloat = .zero
var geometry: CGSize = .zero
var panelStyle: AnyShapeStyle = .init(.white)
...
var body: some View {
let traits = DrawerLayout(...)
...
let gesture = DragGesture(
minimumDistance: .zero,
coordinateSpace: .named(gestureSpace))
...
ZStack(...) {
// Panel view
Color.clear
.background(panelStyle,
in: RoundedRectangle(cornerRadius: cornerRadius))
...
// Content view
content
...
// Handle view
handle
.gesture(gesture)
}
...
.coordinateSpace(name: gestureSpace)
.gesture(gesture, ...)
}
}The minimum drag distance is set to
.zeroto ensure the drawer's drag gesture takes priority over internal controls, preventing scroll views or sliders from hijacking the drag gesture.
This breakdown of the Drawer component highlights the considerations made to ensure it remains flexible and maintainable. By understanding the auxiliary types, API, and internal implementation, you can see how the Drawer succeeds in adapting to various use cases. I hope you found this exploration insightful. Would you agree that the Drawer effectively balances flexibility with functionality?
For more details on the DrawerLayout object and its role in facilitating testability for the Drawer component, check out the Code Testing section.
Code Testing
In UI component development, ensuring that components remain as "dumb" as possible—meaning they handle minimal logic and are mostly concerned with rendering—greatly enhances maintainability and testability. This philosophy guided the design of the Drawer component, where all view-related computations are delegated to a dedicated layout object, DrawerLayout.
The DrawerLayout is a Buildable object responsible for computing and returning the traits necessary for rendering a drawer given a specific CGSize. It encapsulates all the logic related to positioning, sizing, and handling drag gestures, thus allowing the Drawer view to focus purely on rendering.
struct DrawerLayout: Buildable {
let ratio: Binding<CGFloat>
let detent: Binding<Detent>
var allDetents: Set<Detent> = []
var interactiveDetents: Set<Detent> = []
var position: DrawerPosition = .bottom
...
func traits(for geometry: CGSize) -> Traits { ... }
struct Traits: Buildable {
var contentAlignment: Alignment = .bottom
var contentSize: ProposedViewSize = .unspecified
var isContentDisabled = false
var panelOffset: CGPoint = .zero
var panelSize: ProposedViewSize = .unspecified
var onDrag: (CGSize) -> Void = { _ in }
var onDragEnd: (CGSize) -> Void = { _ in }
}
}Within the Drawer component, the _Panel view leverages DrawerLayout to compute all the necessary traits before rendering. This keeps the view "dumb" and defers all the complex logic to DrawerLayout, enabling easy testing and maintenance.
private struct _Panel<Content: View, Handle: View>: View {
let content: Content
let handle: Handle
private let gestureSpace = "drawer_space"
@State private var detent = Detent.small
@State private var ratio = Detent.small.value
var cornerRadius: CGFloat = .zero
var geometry: CGSize = .zero
var panelStyle: AnyShapeStyle = .init(.white)
var position: DrawerPosition = .bottom
var allDetents: Set<Detent> = []
var interactiveDetents: Set<Detent> = []
var body: some View {
let traits = DrawerLayout(ratio: $ratio, detent: $detent)
.set(\.allDetents, to: allDetents)
.set(\.interactiveDetents, to: interactiveDetents)
.set(\.position, to: position)
.traits(for: geometry)
let gesture = DragGesture(
minimumDistance: 10,
coordinateSpace: .named(gestureSpace))
.onChanged(pass(\.translation, to: traits.onDrag))
.onEnded(pass(\.predictedEndTranslation, to: traits.onDragEnd))
ZStack(alignment: traits.contentAlignment) {
// Panel view
Color.clear
.background(panelStyle,
in: RoundedRectangle(cornerRadius: cornerRadius))
.frame(height: traits.panelSize.height)
.frame(width: traits.panelSize.width)
// Content view
content
.disabled(traits.isContentDisabled)
.frame(maxHeight: traits.contentSize.height)
.frame(maxWidth: traits.contentSize.width)
// Handle view
handle
.gesture(gesture)
}
...
.offset(x: traits.panelOffset.x)
.offset(y: traits.panelOffset.y)
.coordinateSpace(name: gestureSpace)
.gesture(gesture,
including: traits.isContentDisabled ? .gesture : .subviews)
}
...
}While it might seem more conventional to use a
@StateObjector@ObservedObjectto manage theDrawerLayout, doing so introduced challenges that conflicted with SwiftUI best practices:
@ObservedObject: This property wrapper implies that the object is maintained outside the view, making it unsuitable for a self-contained UI component like theDrawer. Additionally, creating a private@ObservedObjectwithin the view is discouraged as it leads to the object being recreated whenever the view updates, potentially causing state loss.@StateObject: While@StateObjectensures the state object persists across view updates, it doesn't allow modifying the object’s attributes at runtime, which is necessary for our custom modifiers.Given these constraints, instantiating
DrawerLayoutwithin the view body ensures that layout computations are always current and correctly applied, without the pitfalls of improper state management.
By abstracting behavior into DrawerLayout, you can test various drawer states and scenarios independently from the view. For example, you can verify that a drawer in a bottom position with specific detents correctly updates its ratio and current detent when a drag ends:
final class DrawerLayoutTests: XCTestCase {
var layout: DrawerLayout!
...
func test_negative_drag_end_detents_bottom_position() {
let traits = layout
.set(\.position, to: .bottom)
.set(\.detent.wrappedValue, to: .medium)
.set(\.allDetents, to: [.small, .medium, .large])
.traits(for: CGSize(width: 100, height: 100))
traits.onDragEnd(CGSize(width: -26, height: -26))
XCTAssertEqual(
layout.detent.wrappedValue, .large,
"Incorrect layout detent value."
)
XCTAssertEqual(
layout.ratio.wrappedValue, Detent.large.value,
"Incorrect layout detent value."
)
}
}This approach allows for granular testing of the drawer's logic, ensuring that any defects can be identified and fixed with confidence, while protecting against regressions.