DEV Community

Maksim Ponomarev
Maksim Ponomarev

Posted on

Centered Horizontal Collection in SwiftUI

How to Use CenteredHorizontalCollection framework in Your SwiftUI Project

Image description

The CenteredHorizontalCollection is a powerful SwiftUI component that provides a horizontally scrolling collection with automatic centering, smooth scrolling physics, and enhanced user experience. This article will guide you through implementing and customizing this component in your SwiftUI applications, using the techniques demonstrated in the DemoView implementation.

Image description

Table of Contents
1.Basic Implementation
2.Customizing Item Appearance
3.Handling Selection
4.Configuring Collection Behavior
5.Advanced Theming and Visual Styles
6.Performance Considerations
7.Complete Implementation Example

Basic Implementation
To get started with the CenteredHorizontalCollection, first import the package and create some data to display:

import SwiftUI
import CenteredHorizontalCollection

struct MyView: View {
    // Create some sample items
    let items: [Item] = (1...10).map { Item(id: $0, color: .blue) }

    // Track the selected item
    @State private var selectedID = 1

    var body: some View {
        CenteredHorizontalCollection(items: items) { item, isSelected in
            // Custom item view
            RoundedRectangle(cornerRadius: 8)
                .fill(item.color)
                .frame(width: 60, height: 60)
                .overlay(
                    Text("\(item.id)")
                        .foregroundColor(.white)
                )
                .scaleEffect(isSelected ? 1.1 : 0.9)
        }
        .selection($selectedID)
    }
}
Enter fullscreen mode Exit fullscreen mode

This code creates a horizontal collection of colored rectangles with numbers, where the selected item appears slightly larger.

Customizing Item Appearance
The power of CenteredHorizontalCollection lies in the view builder closure that lets you fully customize how each item appears:

CenteredHorizontalCollection(items: items) { item, isSelected in
    // Custom item view
    ZStack {
        Circle()
            .fill(item.color)
            .frame(width: 60, height: 60)

        Text("\(item.id)")
            .font(.headline)
            .foregroundColor(.white)
    }
    .shadow(radius: isSelected ? 6 : 2)
    .scaleEffect(isSelected ? 1.1 : 0.9)
    .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected)
}
Enter fullscreen mode Exit fullscreen mode

The closure provides two parameters:
item: The data model for this item
isSelected: A boolean indicating whether this item is currently selected
Use these parameters to customize the appearance based on the selection state. Adding animations creates a more polished experience.

Handling Selection
To track which item is selected, bind a state variable to the collection:

@State private var selectedID = 1

var body: some View {
    VStack {
        // Display the selected item
        Text("Selected Item: \(selectedID)")

        CenteredHorizontalCollection(items: items) { item, isSelected in
            // Item view builder
        }
        .selection($selectedID)

        // Navigation buttons
        HStack {
            Button("Previous") {
                if selectedID > 1 {
                    selectedID -= 1
                }
            }
            .disabled(selectedID <= 1)

            Button("Next") {
                if selectedID < items.count {
                    selectedID += 1
                }
            }
            .disabled(selectedID >= items.count)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The .selection() modifier binds your state variable to the collection, allowing two-way updates:
When the user selects an item in the collection, your variable updates
When you change the variable programmatically, the collection scrolls to show that item

Configuring Collection Behavior
The CenteredHorizontalCollection uses an actor-based configuration system that provides thread-safe access to all settings. Configure these at the beginning of your view's lifecycle:

.task {
    // Configure the collection
    await HorizontalCollectionConstants.configure(
        itemSize: 60,          // Size of each item
        itemSpacing: 16,       // Spacing between items
        debugMode: false,      // Enable/disable debug logging
        scrollBehaviorMode: .targetContentOffset  // Enhanced scrolling physics
    )
}
Enter fullscreen mode Exit fullscreen mode

The component supports two scrolling behavior modes:
.standard: Simple selection and centering
.targetContentOffset: Enhanced physics similar to UICollectionView with momentum-based targeting

You can also create UI controls to let users adjust these settings:

@State private var itemSize: Double = 60
@State private var itemSpacing: Double = 16
@State private var debugMode = false
@State private var scrollBehaviorMode = 1  // 0 for standard, 1 for targetContentOffset

// In your view body:
HStack {
    Text("Item Size:")
    Slider(value: $itemSize, in: 40...120, step: 5)
        .onChange(of: itemSize) { value in
            Task {
                await HorizontalCollectionConstants.configure(
                    itemSize: value
                )
            }
        }
    Text("\(Int(itemSize))")
}
Toggle("Debug Mode", isOn: $debugMode)
    .onChange(of: debugMode) { value in
        Task {
            await HorizontalCollectionConstants.configure(debugMode: value)
        }
    }
Enter fullscreen mode Exit fullscreen mode

Advanced Theming and Visual Styles
You can create different visual themes for your collection by implementing a theming system:

@State private var currentTheme = 0
let themes = ["Standard", "Minimalist", "Vibrant", "3D Effect"]

// In your view body:
Picker("Display Style", selection: $currentTheme) {
    ForEach(0..<themes.count, id: \.self) { index in
        Text(themes[index]).tag(index)
    }
}
.pickerStyle(SegmentedPickerStyle())
// Then use the selected theme in your item builder:
CenteredHorizontalCollection(items: items) { item, isSelected in
    itemView(for: item, isSelected: isSelected)
}
// Define a helper method to create themed item views:
private func itemView(for item: Item, isSelected: Bool) -> some View {
    switch currentTheme {
    case 0: // Standard
        return RoundedRectangle(cornerRadius: 8)
            .fill(item.color)
            .overlay(Text("\(item.id)").foregroundColor(.white))
            .scaleEffect(isSelected ? 1.1 : 0.9)

    case 1: // Minimalist
        return Circle()
            .stroke(item.color, lineWidth: isSelected ? 3 : 1)
            .background(Circle().fill(Color.white))
            .overlay(Text("\(item.id)").foregroundColor(item.color))

    // Add more themes as needed
    default:
        return RoundedRectangle(cornerRadius: 8)
            .fill(item.color)
            .overlay(Text("\(item.id)").foregroundColor(.white))
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach allows you to offer multiple visual styles without cluttering your main view code.

Performance Considerations
For optimal performance with CenteredHorizontalCollection:
Configure item size appropriately for your design and device size
Use efficient item views that don’t perform expensive rendering operations
Apply animations carefully — use them for selection state but avoid excessive animations
Turn off debug mode in production builds

Complete Implementation Example
Here’s a complete example of a view using CenteredHorizontalCollection with all the features discussed:

import SwiftUI
import CenteredHorizontalCollection

struct CollectionDemoView: View {
    // Sample data
    let items: [Item] = (1...20).map { Item(id: $0, color: Color(
        red: Double.random(in: 0...1),
        green: Double.random(in: 0...1),
        blue: Double.random(in: 0...1)))
    }

    // State variables
    @State private var selectedID = 1
    @State private var itemSize: Double = 60
    @State private var itemSpacing: Double = 16
    @State private var debugMode = false
    @State private var scrollBehaviorMode = 1  // Enhanced mode
    @State private var currentTheme = 0

    // Theme options
    let themes = ["Standard", "Minimal", "Vibrant"]

    var body: some View {
        VStack(spacing: 16) {
            // Header
            Text("Collection Demo")
                .font(.headline)

            // Selected item display
            Text("Selected: \(selectedID)")
                .padding(.bottom, 8)

            // The collection
            CenteredHorizontalCollection(items: items) { item, isSelected in
                itemView(for: item, isSelected: isSelected)
            }
            .selection($selectedID)

            // Navigation
            HStack(spacing: 20) {
                Button(action: {
                    if selectedID > 1 {
                        selectedID -= 1
                    }
                }) {
                    Label("Previous", systemImage: "chevron.left")
                        .padding(.horizontal, 10)
                        .padding(.vertical, 5)
                        .background(Color.blue.opacity(0.1))
                        .cornerRadius(8)
                }
                .disabled(selectedID <= 1)

                Button(action: {
                    if selectedID < items.count {
                        selectedID += 1
                    }
                }) {
                    Label("Next", systemImage: "chevron.right")
                        .padding(.horizontal, 10)
                        .padding(.vertical, 5)
                        .background(Color.blue.opacity(0.1))
                        .cornerRadius(8)
                }
                .disabled(selectedID >= items.count)
            }
            .padding()

            // Theme selection
            Picker("Style", selection: $currentTheme) {
                ForEach(0..<themes.count, id: \.self) { index in
                    Text(themes[index]).tag(index)
                }
            }
            .pickerStyle(SegmentedPickerStyle())
            .padding(.horizontal)

            // Configuration controls
            VStack(alignment: .leading) {
                Text("Configuration")
                    .font(.subheadline)
                    .fontWeight(.bold)

                // Size and spacing sliders
                HStack {
                    Text("Size:")
                    Slider(value: $itemSize, in: 40...120, step: 5)
                        .onChange(of: itemSize) { value in
                            Task {
                                await HorizontalCollectionConstants.configure(
                                    itemSize: value
                                )
                            }
                        }
                    Text("\(Int(itemSize))")
                        .frame(width: 30)
                }

                HStack {
                    Text("Spacing:")
                    Slider(value: $itemSpacing, in: 4...40, step: 2)
                        .onChange(of: itemSpacing) { value in
                            Task {
                                await HorizontalCollectionConstants.configure(
                                    itemSpacing: value
                                )
                            }
                        }
                    Text("\(Int(itemSpacing))")
                        .frame(width: 30)
                }

                // Mode toggles
                Toggle("Debug Mode", isOn: $debugMode)
                    .onChange(of: debugMode) { value in
                        Task {
                            await HorizontalCollectionConstants.configure(debugMode: value)
                        }
                    }

                Picker("Scroll Behavior", selection: $scrollBehaviorMode) {
                    Text("Standard").tag(0)
                    Text("Enhanced").tag(1)
                }
                .pickerStyle(SegmentedPickerStyle())
                .onChange(of: scrollBehaviorMode) { value in
                    Task {
                        await HorizontalCollectionConstants.configure(
                            scrollBehaviorMode: value == 0 ? .standard : .targetContentOffset
                        )
                    }
                }

                // Reset button
                Button("Reset Settings") {
                    resetToDefaults()
                }
                .padding(.top, 8)
            }
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(12)
            .padding(.horizontal)
        }
        .padding()
        .task {
            // Initial configuration
            await HorizontalCollectionConstants.configure(
                itemSize: itemSize,
                itemSpacing: itemSpacing,
                debugMode: debugMode,
                scrollBehaviorMode: scrollBehaviorMode == 0 ? .standard : .targetContentOffset
            )
        }
    }

    // Helper method for themed item views
    private func itemView(for item: Item, isSelected: Bool) -> some View {
        Group {
            switch currentTheme {
            case 0: // Standard
                RoundedRectangle(cornerRadius: 8)
                    .fill(item.color)
                    .frame(width: itemSize, height: itemSize)
                    .overlay(
                        Text("\(item.id)")
                            .foregroundColor(.white)
                            .fontWeight(.bold)
                    )
                    .shadow(radius: isSelected ? 6 : 2)
                    .scaleEffect(isSelected ? 1.1 : 0.9)

            case 1: // Minimalist
                Circle()
                    .stroke(item.color, lineWidth: isSelected ? 3 : 1)
                    .background(Circle().fill(Color.white))
                    .frame(width: itemSize, height: itemSize)
                    .overlay(
                        Text("\(item.id)")
                            .foregroundColor(item.color)
                            .fontWeight(isSelected ? .bold : .regular)
                    )
                    .scaleEffect(isSelected ? 1.05 : 1.0)

            case 2: // Vibrant
                ZStack {
                    Circle()
                        .fill(item.color)
                    Circle()
                        .fill(item.color.opacity(0.7))
                        .scaleEffect(0.8)
                    Circle()
                        .fill(Color.white.opacity(0.9))
                        .scaleEffect(0.5)
                    Text("\(item.id)")
                        .font(.system(size: itemSize * 0.3))
                        .fontWeight(.black)
                        .foregroundColor(item.color)
                }
                .frame(width: itemSize, height: itemSize)
                .shadow(color: item.color.opacity(0.5), radius: isSelected ? 10 : 2)
                .scaleEffect(isSelected ? 1.15 : 0.95)

            default:
                RoundedRectangle(cornerRadius: 8)
                    .fill(item.color)
                    .frame(width: itemSize, height: itemSize)
                    .overlay(Text("\(item.id)").foregroundColor(.white))
            }
        }
        .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected)
    }

    // Reset to default settings
    private func resetToDefaults() {
        itemSize = 60
        itemSpacing = 16
        debugMode = false
        scrollBehaviorMode = 1 // Enhanced
        currentTheme = 0

        Task {
            await HorizontalCollectionConstants.configure(
                itemSize: 60,
                itemSpacing: 16,
                debugMode: false,
                scrollBehaviorMode: .targetContentOffset
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion
The CenteredHorizontalCollection component provides a powerful way to create horizontally scrolling collections with professional-quality behavior in SwiftUI. By leveraging its customization options and configuration system, you can create collections that perfectly match your design needs while providing a smooth, polished user experience.
Key benefits of using this component include:
Enhanced scrolling physics similar to UICollectionView
Complete customization of item appearance
Thread-safe configuration via Swift actors
Support for multiple visual themes
Easy integration with existing SwiftUI code
Start by implementing the basic collection, then gradually add customizations to create exactly the experience you want for your users.
The git you can find here: https://github.com/Maxnxi/CenteredHorizontalCollection_SwiftUI
and DemoView: https://github.com/Maxnxi/CenteredHorizontalCollection_SwiftUI/tree/main/Examples

Top comments (0)