I’m finally getting my hands dirty with SwiftUI and generally find it more motivating to build components that I actually need. So I thought perhaps that it’s time I re-write my Songbook Simple and Recipebox Simple apps to leverage all the experience I’ve gained with Swift, Design Patterns, Test-driven development, and to see how I might build the app in SwiftUI.
Most of Songbook’s UI is pretty simple and straightforward, with one exception: the Main Container UI. Basically Songbook has 3 principal UI components: The Song / Content View (rendering text or images), a Menu panel that slides in/out, and a toolbar that can show/hide.
I basically took this blog post, married it with this other blog post by the same author, to get an understanding of how to make a side panel that animates in and out, then take this approach and see if it can work in 2 dimensions. It wasn’t as straightforward to do the bottom panel, but it did teach me about the importance of GeometryReader and gave me some insights as to how SwiftUI handles state changes, and how to build a view hierarchy that takes that into consideration.
I am grateful to be able to stand on someone else’s shoulders to get me far enough along, after which I synthesized the rest from the knowledge gained. So I’ll just jump to how I got to the result.
It ultimately yielded these components:
//
// ControlsOverlayView.swift
// BookItemsSuite
//
// Created by Stephen O'Connor on 11.03.21.
//
import SwiftUI
struct ControlsOverlayView<ToolbarContent: View, MenuContent: View>: View {
let menuWidth: CGFloat
let isMenuActive: Bool
let onMenuHide: () -> Void
let menuContent: MenuContent
let toolbarHeight: CGFloat
let isToolbarActive: Bool
let onToolbarHide: () -> Void
let toolbarContent: ToolbarContent
init(menuWidth: CGFloat = 270,
isMenuActive: Bool = true,
onMenuHide: @escaping () -> Void,
toolbarHeight: CGFloat = 44,
isToolbarActive: Bool = true,
onToolbarHide: @escaping () -> Void,
@ViewBuilder menuContent: () -> MenuContent,
@ViewBuilder toolbarContent: () -> ToolbarContent) {
self.menuWidth = menuWidth
self.isMenuActive = isMenuActive
self.onMenuHide = onMenuHide
self.menuContent = menuContent()
self.toolbarHeight = toolbarHeight
self.isToolbarActive = isToolbarActive
self.onToolbarHide = onToolbarHide
self.toolbarContent = toolbarContent()
}
var body: some View {
ZStack {
GeometryReader { _ in
EmptyView()
}
.background(Color.gray.opacity(0.3))
.opacity(self.isMenuActive ? 1.0 : 0.0)
.animation(Animation.easeIn.delay(0.25))
.onTapGesture {
self.onMenuHide()
}
GeometryReader { geometry in
VStack {
let toolbarHeight = isToolbarActive ? self.toolbarHeight : 0
HStack {
Spacer()
let space: CGFloat = 0.0 //geometry.size.width - self.menuWidth
let offset = self.isMenuActive ? space : space + self.menuWidth
self.menuContent
.frame(width: self.menuWidth, height: geometry.size.height - toolbarHeight, alignment: .center)
.background(Color.red)
.offset(x: offset)
.animation(.default)
}
.frame(width: geometry.size.width,
height: geometry.size.height - toolbarHeight,
alignment: .center)
let offset = self.isToolbarActive ? 0 : self.toolbarHeight/2
self.toolbarContent
.frame(width: geometry.size.width,
height: self.toolbarHeight,
alignment: .center)
.background(Color.yellow)
.offset(y: offset)
.animation(.default)
}
}
}
}
}
struct ControlsOverlayView_Previews: PreviewProvider {
static var previews: some View {
ControlsOverlayView(menuWidth: 270,
isMenuActive: true,
onMenuHide: {},
toolbarHeight: 44,
isToolbarActive: true,
onToolbarHide: {}) {
Text("Menu Content")
} toolbarContent: {
Text("Toolbar Content")
}
}
}
Which is then a part of the ApplicationView (in UIKit terms, this would equate to a root view controller):
//
// ApplicationView.swift
// SidePanelSandbox
//
// Created by Stephen O'Connor on 10.03.21.
//
import SwiftUI
struct ApplicationView<MainContent: View, MenuContent: View, ToolbarContent: View>: View {
@State var isMenuActive: Bool = true
@State var isToolbarActive: Bool = false
var menuWidth: CGFloat = 270
let mainContent: MainContent
let menuContent: MenuContent
let toolbarContent: ToolbarContent
init(@ViewBuilder mainContent: () -> MainContent,
menuContent: () -> MenuContent,
toolbarContent: () -> ToolbarContent) {
self.mainContent = mainContent()
self.menuContent = menuContent()
self.toolbarContent = toolbarContent()
}
var body: some View {
ZStack {
GeometryReader { geometry in
self.mainContent
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .center)
}
if !self.isMenuActive {
GeometryReader { _ in
VStack {
Button(action: {
self.toggleMenu()
}, label: {
Text("Menu")
})
Button(action: {
self.toggleToolbar()
}, label: {
Text("Toolbar")
})
}
}
}
ControlsOverlayView(menuWidth: 270, isMenuActive: isMenuActive, onMenuHide: {
self.toggleMenu()
print("did hide menu")
}, toolbarHeight: 44, isToolbarActive: isToolbarActive, onToolbarHide: {
print("did hide toolbar")
}, menuContent: {
self.menuContent
}, toolbarContent: {
self.toolbarContent
})
}
}
func toggleMenu() {
if self.isMenuActive {
// if we're about to close the menu, close the toolbar too.
$isToolbarActive.wrappedValue = false
}
self.isMenuActive.toggle()
}
func toggleToolbar() {
self.isToolbarActive.toggle()
}
}
struct ApplicationView_Previews: PreviewProvider {
static var previews: some View {
ApplicationView {
Text("Main Content")
} menuContent: {
Text("Menu Content")
} toolbarContent: {
Text("Toolbar Content")
}
}
}
And that’s essentially how it works. I was and still am a little confused as to why the ControlsOverlayView is rendering as expected, given the way I offset the toolbar, but all in all I think this approach is pretty clean and I’m happy with it.