Skip to content

Tre-Ellis-Cooper/Ex.ChipGroup

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Explore an example, Material-inspired ChipGroup.

In the Example Series, we engineer solutions to custom UI/UX
systems and components, focusing on production quality code.
Stay tuned for updates to the series:

Follow


LinkedIn  Twitter
Instagram


Repo Size  Last Commit


Usage

Using the ChipGroup is easy:

  • Initialize a ChipGroup with a collection of Identifiable elements and a closure to convert each element into your desired View.
struct ExampleView: View {
    let elements: [Element] // Any Identifiable Collection

    var body: some View {
        ChipGroup(elements: elements) { element in
            Text(element.name) // Any view building closure
        }
    }
}

Try adding the Source directory to your project to use the chip group in your app!

Exploration

Code Design

Code Design

To make sure the ChipGroup could be easily adapted without changing its implementation, I opted for an initializer that resembles the ForEach element:

struct ChipGroup<Element: Identifiable, Chip: View>: View {
    let chipView: (Element) -> Chip
    let elements: [Elements]

    ...
    
    init(elements: [Element],
         @ViewBuilder chipView: @escaping (Element) -> Chip) {
        self.chipView = chipView
        self.elements = elements
    }

    ...
}

This way, like the ForEach, we end up with an implementation decoupled from any predetermined data source or visual treatment. We are free to provide any chip view and backing data structure we like:

struct ExampleView: View {
    let elements: [Element] // Any Identifiable Collection

    var body: some View {
        ChipGroup(elements: elements) { element in
            Text(element.name) // Any view building closure
        }
    }
}

Two components help to ensure that the ChipGroup works properly in any layout: Spacer and GeometryReader. The Spacer forces the ChipGroup to expand to fill its container horizontally. While the GeometryReader provides the container width and the chip sizes for logic.

Typically, we would wrap our component in a GeometryReader to access the container size. However, that doesn't provide what we expect if the element happens to be in a ScrollView. Instead, I placed a GeometryReader in the background of a Spacer to determine the allowed width, like so:

Spacer()
    .background(
        GeometryReader { proxy in
            ...
        }
    )

This forces the ChipGroup to expand to fill its container and then exposes the container width using a GeometryReader.

I rely on the same approach to determine the size of the chip views, so I created two reusable view modifiers for readability that encapsulate this approach: relaySizeData and readSizeData. These modifiers use a custom PreferenceKey to make the view's size available to parent views:

func readSizeData<KeyType: Hashable>(
    closure: @escaping ([KeyType: CGSize]
) -> Void) -> some View {
    self.onPreferenceChange(SizePreference<KeyType>.self, perform: closure)
}

func relaySizeData<KeyType: Hashable>(withKey key: KeyType) -> some View {
    self.background(
        GeometryReader { proxy in
            Spacer()
                .preference(
                    key: SizePreference<KeyType>.self, 
                    value: [key: proxy.size]
                )
        }
    )
}

By using these modifiers to access the container width and chip sizes, the ChipGroup can dynamically position its chips and maintain an intrinsic height:

struct ChipGroup<Element: Identifiable, Chip: View>: View {
    let chipView: (Element) -> Chip
    let elements: [Element]
    
    @State private var chipSizes = [Element.ID: CGSize]()
    @State private var allowedWidth = CGFloat.zero

    ...
    
    var body: some View {
        let traits = ChipGroupLayout(elements: elements)
            .traits(for: chipSizes, in: allowedWidth, with: chipSpacing)
        
        return VStack(spacing: .zero) {
            Spacer()
                ...
                .relaySizeData(withKey: allowedWidthKey)
            ZStack(alignment: .topLeading) {
                ForEach(traits) { trait in
                    chipView(trait.element)
                        ...
                        .relaySizeData(withKey: trait.element.id)
                        .alignmentGuide(.leading) { _ in -trait.position.x }
                        .alignmentGuide(.top) { _ in -trait.position.y }
                }
            }

            ...
        }
        .readSizeData { chipSizes = $0 }
        .readSizeData(forKey: allowedWidthKey) { allowedWidth = $0.width }
    }
    
    ...
}

After walking through how the ChipGroup is built, would you agree it's easy to use and adapt to different use cases!? Check out the Code Testing section for more information on the ChipGroupLayout object.

Code Testing

Code Testing

To facilitate testability, I took some theory from the Strategy Behavioral pattern and abstracted the layout algorithm into an object:

struct ChipGroupLayout<Element: Identifiable> {
    let elements: [Element]

    ...
}

The ChipGroupLayout has a single function that accepts the layout parameters and returns an array of ChipGroupLayout.Trait: a simple wrapper around the supplied Identifiable that includes a position pointer.

struct ChipGroupLayout<Element: Identifiable> {
    ...

    func traitsForChipSizes(
        _ chipSizes: [Element.ID: CGSize],
        in containerWidth: CGFloat,
        with spacing: Spacing
    ) -> [Trait] {
        ...
    }

    ...

    struct Trait: Identifiable {
        let element: Element
        let position: CGPoint
        
        var id: Element.ID {
            element.id
        }
    }
}

The ChipGroup element calls this function and uses the alignmentGuide modifier to position the chip views according to the trait objects, like so:

struct ChipGroup<Element: Identifiable, Chip: View>: View {
    ...
    
    var body: some View {
        let layout = ChipGroupLayout(elements: elements)
        let traits = layout.traits(
            for: chipSizes,
            in: allowedWidth,
            with: chipSpacing
        )
        
        return VStack(spacing: .zero) {
            ...
            ZStack(alignment: .topLeading) {
                ForEach(layoutTraits) { trait in
                    chipView(trait.element)
                        ...
                        .alignmentGuide(.leading) { _ in -trait.position.x }
                        .alignmentGuide(.top) { _ in -trait.position.y }
                }
            }

            ...
        }

        ...
    }

By owning the layout algorithm, the ChipGroupLayout keeps the ChipGroup view dumb and makes the chip-positioning logic easily testable. For example:

final class ChipGroupLayoutTests: XCTestCase {
    func test_execute_layoutTraitsForChipSizes() {
        let elements = [
            Chip(title: "Chip1"),
            Chip(title: "Chip2"),
            Chip(title: "Chip3"),
            Chip(title: "Chip4"),
            Chip(title: "Chip5")
        ]
        let elementSizes = [
            "Chip1": CGSize(width: 20, height: 10),
            "Chip2": CGSize(width: 20, height: 15),
            "Chip3": CGSize(width: 30, height: 10),
            "Chip4": CGSize(width: 30, height: 10),
            "Chip5": CGSize(width: 10, height: 10)
        ]

        let allowedWidth: CGFloat = 50
        let spacing: ChipGroupLayout.Spacing = (horizontal: 5, vertical: 5)
        
        let layout = ChipGroupLayout(elements: elements)
        let traits = layout.traits(for: elementSizes, 
                                   in: allowedWidth, 
                                   with: spacing)

        let correctPositions = [
            "Chip1": CGPoint(x: 0, y: 0),
            "Chip2": CGPoint(x: 25, y: 0),
            "Chip3": CGPoint(x: 0, y: 20),
            "Chip4": CGPoint(x: 0, y: 35),
            "Chip5": CGPoint(x: 35, y: 35),
        ]
        
        for trait in traits {
            XCTAssertEqual(trait.position,
                           correctPositions[trait.id],
                           "Trait doesn't have expected position value.")
        }
    }
}

We can test to make sure the ChipGroupLayout positions chips the way we would expect given any container width, chip sizes, and chip spacing.

Do you agree that the design is adaptative and easy to use? Have any questions, comments, or just want to give feedback? Share your ideas with me on social media:

LinkedIn  Twitter  Instagram

About

An example implementation of a Material-inspired "ChipGroup" in SwiftUI.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages