Greetings, traveler!
Have you ever heard about a ControlGroup in SwiftUI? This tool can help create groups of UI components in SwiftUI Views, particularly SwiftUI Menus.
Example
We can create something like Stepper with this component.
struct ControlGroupView: View {
var body: some View {
ControlGroup {
Button {
} label: {
Image(systemName: "plus")
}
Button {
} label: {
Image(systemName: "minus")
}
}
}
}We can also use some specific modifiers to create a desired layout.
struct ControlGroupView: View {
var body: some View {
ControlGroup {
Button {
} label: {
Image(systemName: "plus")
}
Button {
} label: {
Image(systemName: "minus")
}
}
.controlGroupStyle(.automatic)
}
}Menu
We can use the same approach to create a row for the SwiftUI Menu containing a control group.
First, let’s create regular menu items.
struct ContentView: View {
private var menuView: some View {
ForEach(0...3, id: \.self) { number in
Button("Menu item \(number)") {}
}
}
}Then, let’s create a player control view, which will be displayed in one line.
struct ContentView: View {
private var menuView: some View {
ForEach(0...3, id: \.self) { number in
Button("Menu item \(number)") {}
}
}
private var playerView: some View {
ControlGroup {
Button {
print("previous track")
} label: {
Image(systemName: "backward")
}
Button {
print("play")
} label: {
Image(systemName: "play")
}
Button {
print("next track")
} label: {
Image(systemName: "forward")
}
}
}
}Now, let’s put it all together.
struct ContentView: View {
var body: some View {
Menu {
menuView
playerView
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
}
}
}Nice!
Full playground:
import SwiftUI
struct ContentView: View {
@State private var count: Int = 100
private enum RunMode {
case none
case play
case backward
case forward
}
@State private var runMode: RunMode = .none
@State private var runnerTask: Task<Void, Never>? = nil
var body: some View {
NavigationStack {
VStack {
Text(count.description)
.font(.system(size: 100, weight: .black))
.fontDesign(.rounded)
.contentTransition(.numericText())
.animation(.bouncy, value: count)
}
.gradientBackground()
.safeAreaInset(edge: .bottom) {
GroupBox {
VStack(spacing: 50) {
customStepper
playerView
}
}
.clipShape(.rect(cornerRadius: 33))
.padding()
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
playerView
menuView
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
}
}
ToolbarItem(placement: .topBarLeading) {
playerView.controlGroupStyle(.navigation)
}
}
.onDisappear {
stop()
}
}
}
private var customStepper: some View {
ControlGroup {
Button {
count -= 1
} label: {
Image(systemName: "minus")
}
Button {
count += 1
} label: {
Image(systemName: "plus")
}
}
}
private var playerView: some View {
ControlGroup {
Button {
if runMode == .backward {
stop()
} else {
start(mode: .backward)
}
} label: {
Image(systemName: "backward")
}
Button {
if runMode == .none {
start(mode: .play)
} else {
stop()
}
} label: {
Image(systemName: runMode == .none ? "play" : "stop")
}
Button {
if runMode == .forward {
stop()
} else {
start(mode: .forward)
}
} label: {
Image(systemName: "forward")
}
}
}
private var menuView: some View {
ForEach(0...3, id: \.self) { number in
Button("Menu item \(number)") {}
}
}
private func start(mode: RunMode) {
stop()
runMode = mode
runnerTask = Task { [mode] in
while !Task.isCancelled {
await MainActor.run {
switch mode {
case .play, .forward:
count += 1
case .backward:
count -= 1
case .none:
break
}
}
let interval: UInt64
switch mode {
case .play:
interval = 1_000_000_000
case .backward, .forward:
interval = 200_000_000
case .none:
interval = 0
}
do {
try await Task.sleep(nanoseconds: interval)
} catch {
break
}
}
}
}
private func stop() {
runnerTask?.cancel()
runnerTask = nil
runMode = .none
}
}
private extension View {
func gradientBackground() -> some View {
ZStack {
LinearGradient(
colors: [
.blue.opacity(0.15),
.cyan.opacity(0.15),
.mint.opacity(0.15),
.blue.opacity(0.15)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
self
}
.ignoresSafeArea()
}
}
#Preview {
ContentView()
}