How to organize layout with ControlGroup in SwiftUI


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()
}