We say a
lot about...
everything

Ars Futura Listed in Financial Times Among Europe’s 1000 Fastest-Growing Companies
news
culture

Ars Futura Listed in Financial Times Among Europe’s 1000 Fastest-Growing Companies

Popular Articles

Tournament Brackets in SwiftUI

A step-by-step guide to building tournament brackets with SwiftUI

Published on July 20, 2023

by

Roko JurićRoko Jurić

Filed under development

Intro

In this blog, we’ll explore how to create tournament brackets in SwiftUI, just like the name says. Recently, our client wanted a new and mobile-friendly way to display brackets natively, instead of relying on a WebView and endlessly scrolling on all axes. So I started looking at libraries, and soon stumbled upon a UIKit solution that served as an inspiration for what I’m about to show you. We’ll dive into the code pretty quickly, building a custom view that can handle user interaction, and animate transitions between rounds. Whether you're building a sports app, or just looking to enhance your SwiftUI skills, this tutorial will give you the tools to create dynamic and engaging tournament brackets that will captivate your users.

All of the code in this blog is written in Xcode 14.2 and iOS 16.

The finished product will look like this:

Step 1: Horizontal scrolling

struct ContentView: View {
    // 1
    private let columnWidth: CGFloat = UIScreen.main.bounds.width * 0.9
    private let colors: [Color] = [Color.red, Color.green, Color.blue]
    
    var body: some View {
        // 2
        ScrollView(.horizontal, showsIndicators: false) {
            columns
        }
    }
    
    // 3
    private var columns: some View {
        HStack(spacing: 0) {
            ForEach(colors, id: \.hashValue) { color in
                color
                    .frame(width: columnWidth)
            }
        }
        .frame(width: CGFloat(colors.count) * columnWidth)
    }
    
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
  • Firstly, we’re defining the column width so there are no magic numbers repeating all over the place. We’re setting the column width to be at 90% of the screen width so that we can see more than one column on the screen at the same time.

  • Inside the body, we’re creating a horizontal ScrollView which takes in the columns view as its content.

  • Inside the columns computed property, we’re simply adding 3 colors to the HStack and setting their width to columnWidth.

If you’ve followed the steps correctly so far, your app should look like this:

Step 2: Snap scrolling and gestures

After we’ve created the basic column layout and scrolling, we should add the snapping feature. Unfortunately, there isn’t a built-in way to accomplish this, so we’ll have to play around with gestures and offsets.

The way to achieve snapping is to manually control the dragging and content offset inside the ScrollView. In order to do it, we’re going to introduce the .gesture() modifier and pass in a DragGesture as a parameter. Also, let’s disable the ScrollView so that it doesn’t cancel our gestures. SwiftUI gestures come with some useful functions, and we’ll be using onChanged and onEnded to keep track of offsets. Inside the onChanged callback, we’re going to be modifying the finger drag offset, and inside onEnded, we’re going to be resetting it to 0, and updating the total offset. To keep track of these inside ContentView, we’re going to add properties and functions, just to tidy things up a bit.

  • First off, let’s disable the ScrollView. To accomplish this in iOS 16, use the .scrollDisabled(true) modifier on the ScrollView.

  • Secondly, add the dragOffsetX and focusedColumnIndex state properties. The dragOffsetX property will keep track of the gesture drag amount, and the focusedColumnIndex will keep track of, well, focused column index.

  • Moving on, create the function that we’ll use as an onChanged callback. Here, we’ll set the dragOffsetX to the drag gesture translation width.

  • Let’s also create a function called handleDragEnded, which we’ll pass as a callback in onDragEnded. Here, we’ll calculate if we should snap left, right, or if we should stay on the same column. We’ve also added some helper functions to help clean up handleDragEnded a bit.

  • Add .offset(x: offsetX) on columns, here we’ll make the calculation to properly snap the views. Add the offsetX computed var and leave it empty for now.

The helper functions are called moveToLeft, moveToRight, and stayAtSameIndex. Inside all of them, we’re resetting dragOffsetX back to 0, we’re subtracting 1 from focusedColumnIndex inside moveToLeft, and inside moveToRight we’re adding 1 to focusedColumnIndex.

If you followed all of the steps, you should have code that closely resembles the one below:

struct ContentView: View {

    ...

    // 2
    @State private var dragOffsetX: CGFloat = 0
    @State private var focusedColumnIndex: Int = 0

    private var offsetX: CGFloat {
        // TODO: Offset Calculation for current focused column
    }

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            columns
            // 5
                .offset(x: dragOffsetX)
        }
        .frame(width: UIScreen.main.bounds.size.width)
        // 1
        .scrollDisabled(true)
        .gesture(DragGesture(minimumDistance: 12, coordinateSpace: .global)
            .onChanged(updateCurrentOffsetX)
            .onEnded(handleDragEnded)
        )
    }

    private var columns: some View {
        HStack(spacing: 0) {
            ForEach(colors, id: \.hashValue) { color in
                color
                    .frame(width: columnWidth)
            }
        }
        .frame(width: CGFloat(numberOfColumns) * columnWidth)
    }

    private var numberOfColumns: Int {
        colors.count
    }

    // 3
    private func updateCurrentOffsetX(_ dragGestureValue: DragGesture.Value) {
        dragOffsetX = dragGestureValue.translation.width
    }

    // 4
    private func handleDragEnded(_ gestureValue: DragGesture.Value) {
        // TODO: Decide to stay at the same column, or snap to some other column.
    }

    private func moveToLeft() {
        withAnimation {
            focusedColumnIndex -= 1
            dragOffsetX = 0
        }
    }

    private func moveToRight() {
        withAnimation {
            focusedColumnIndex += 1
            dragOffsetX = 0
        }
    }

    private func stayAtSameIndex() {
        withAnimation {
            dragOffsetX = 0
        }
    }
}

    ...

In this state, the app still doesn’t scroll, nor does it snap, but we’ll get to that soon. If you think this might not work, you can return dragOffsetX from offsetX and you’ll see that the gesture works, but since we haven’t implemented snapping yet, it just stays where you left it, and then snaps back to start once you start dragging again. Fix for that is coming up.

Let’s set some ground rules first:

  • If the swipe length is less than half of the column width, we’re not changing columns.

  • We can’t allow snapping from inside to outside of the columns.

With these in mind, let’s continue.

  • First, to clean up, create a constant which will store the isScrollingRight Bool. To calculate this, we check if the user swiped left, i.e. if the dragOffset is a negative value.

  • Secondly, we check if the user scrolled far enough. Store this in a constant called didScrollEnough. To calculate this, check if the absolute value of the user scroll distance is bigger than half of the column width.

  • Now, if didScrollEnough is true - continue. Otherwise call the stayAtSameIndex function and return.

  • If the user is trying to scroll right, and we’re not at the last column, call the moveToRight() function.

  • If the user is trying to scroll left, and we’re not at the first column, call the moveToLeft() function.

  • Otherwise, stayAtSameIndex().

struct ContentView: View {

    ...
    
    private var offsetX: CGFloat {
        // 3
        dragOffsetX
    }
    
    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            columns
                .offset(x: offsetX)
        }
        .frame(width: UIScreen.main.bounds.size.width)
        .scrollDisabled(true)
        .gesture(DragGesture(minimumDistance: 12, coordinateSpace: .global)
            .onChanged(updateCurrentOffsetX)
            .onEnded(handleDragEnded)
        )
    }
    
    ...
    
    private func handleDragEnded(_ gestureValue: DragGesture.Value) {
        // 1
        let isScrollingRight = dragOffsetX < 0
        
        //2
        let didScrollEnough = abs(gestureValue.translation.width) > columnWidth * 0.5
        
        let isFirstColumn = focusedColumnIndex == 0
        let isLastColumn = focusedColumnIndex == numberOfColumns - 1
        
        // 3
        guard didScrollEnough else {
            return stayAtSameIndex()
        }
        
        // 4
        if isScrollingRight, !isLastColumn {
            moveToRight()
            
        //5
        } else if !isScrollingRight, !isFirstColumn {
            moveToLeft()
            
        // 6
        } else {
            stayAtSameIndex()
        }
    }
    
    ...
    
}

Now, if you’ve been following closely, you should see that we always snap back to the first column after the dragging stops. This is because we have not yet implemented the offsetX calculation. We’ll do this now.

Inside offsetX, make a calculation to bring the currently focused column - into focus. To scroll the ScrollView to the right, we need to add a negative offset, so for each column index, we need to add a negative columnWidth to offsetX. In the end, add on a dragOffsetX so that we can still drag the view around. You should end up with something like this:

struct ContentView: View {
    
    ...
    
    private var offsetX: CGFloat {
        -CGFloat(focusedColumnIndex) * columnWidth + dragOffsetX
    }
    
    ...
    
}

Voila! 🎉 Just like that, we have snapping! In the next section, we’ll see how to implement the actual brackets.

Step 3: Vertical scroll and match cells

Colors are lovely, but after some time, they get boring. So let’s spice things up and add the actual brackets. To start things off, we’ll add some models which will help us with visualization later on. These can be customized to your needs and liking, but they’re simple enough for this example.

struct MatchData: Identifiable {
    let team1: String
    let team2: String
    let team1Score: Int
    let team2Score: Int
    
    var id: String {
        team1 + team2
    }
}
struct Bracket {
    let name: String
    let matches: [MatchData]
}

In order to keep things nice and tidy, we’ll separate the column view into its own struct, in our case, BracketColumnView.

struct BracketColumnView: View {
    // 1
    let bracket: Bracket
    
    var body: some View {
        LazyVStack(spacing: 0) {
        
            // 2
            ForEach(bracket.matches) { matchData in
                BracketCell(matchData: matchData)
            }
        }
    }
}

struct BracketColumnView_Previews: PreviewProvider {
    private static let previewBracket: Bracket = Bracket(name: "Semi Finals",
                                                         matches: [
                                                            .init(team1: "Team 1", team2: "Team 2", team1Score: 2, team2Score: 1),
                                                            .init(team1: "Team 3", team2: "Team 4", team1Score: 1, team2Score: 2),
                                                            .init(team1: "Team 5", team2: "Team 6", team1Score: 1, team2Score: 2),
                                                            .init(team1: "Team 7", team2: "Team 8", team1Score: 3, team2Score: 0),
                                                        ])
    
    static var previews: some View {
        BracketColumnView(bracket: previewBracket)
            .padding(.horizontal)
    }
}
  • To create a BracketColumnView, we need to pass in a Bracket to display.

  • Inside the body, we’re looping over the matches, and creating a BracketCell for each match. BracketCell code can be styled as you need, but you can always use our code, find it here.

Now that we can see this is working, let’s replace the colors with Brackets. Also, we’ll wrap everything inside a vertical ScrollView so that we can see the matches that are offscreen. Inside ContentView:

  • Replace colors with brackets,

  • wrap the vertical ScrollView inside a horizontal ScrollView,

  • add top alignment to HStack (this will come in handy later),

  • instead of looping over colors, loop over brackets and render BracketColumnView,

  • inside numberOfColumns replace colors with brackets,

  • add some preview data in previews (keep it 8-4-2-1).

This is how ContentView looks on our end, yours should look similar.

struct ContentView: View {

    ...
    
    // 1
    let brackets: [Bracket]
    
    ...
    
    var body: some View {
        // 2
        ScrollView(.vertical, showsIndicators: false) {
            ScrollView(.horizontal, showsIndicators: false) {
                columns
                    .offset(x: offsetX)
            }
            .frame(width: UIScreen.main.bounds.size.width)
            .scrollDisabled(true)
            .gesture(DragGesture(minimumDistance: 12, coordinateSpace: .global)
                .onChanged(updateCurrentOffsetX)
                .onEnded(handleDragEnded)
            )
        }
    }
    
    private var columns: some View {
        // 3
        HStack(alignment: .top, spacing: 0) {
            // 4
            ForEach(brackets, id: \.name) { bracket in
                BracketColumnView(bracket: bracket)
                    .frame(width: columnWidth)
            }
        }
        .frame(width: CGFloat(numberOfColumns) * columnWidth)
    }
    
    // 5
    private var numberOfColumns: Int {
        brackets.count
    }
    
    ...
    
}

struct ContentView_Previews: PreviewProvider {
    
    // 6
    @State static private var brackets: [Bracket] = [
        Bracket(name: "Eights", matches: [
            MatchData(team1: "Team 1", team2: "Team 2", team1Score: 3, team2Score: 0),
            MatchData(team1: "Team 3", team2: "Team 4", team1Score: 1, team2Score: 2),
            MatchData(team1: "Team 5", team2: "Team 6", team1Score: 2, team2Score: 0),
            MatchData(team1: "Team 7", team2: "Team 8", team1Score: 0, team2Score: 3),
            MatchData(team1: "Team 9", team2: "Team 10", team1Score: 1, team2Score: 2),
            MatchData(team1: "Team 11", team2: "Team 12", team1Score: 3, team2Score: 1),
            MatchData(team1: "Team 13", team2: "Team 14", team1Score: 2, team2Score: 0),
            MatchData(team1: "Team 15", team2: "Team 16", team1Score: 1, team2Score: 2)
        ]),
        Bracket(name: "Quarter Finals", matches: [
            MatchData(team1: "Team 1", team2: "Team 4", team1Score: 3, team2Score: 0),
            MatchData(team1: "Team 5", team2: "Team 8", team1Score: 1, team2Score: 2),
            MatchData(team1: "Team 10", team2: "Team 11", team1Score: 2, team2Score: 0),
            MatchData(team1: "Team 13", team2: "Team 16", team1Score: 0, team2Score: 3),
        ]),
        Bracket(name: "Semi Finals", matches: [
            MatchData(team1: "Team 1", team2: "Team 8", team1Score: 3, team2Score: 0),
            MatchData(team1: "Team 10", team2: "Team 16", team1Score: 1, team2Score: 2),
        ]),
        Bracket(name: "Grand Finals", matches: [
            MatchData(team1: "Team 1", team2: "Team 16", team1Score: 1, team2Score: 2)
        ])
    ]
    
    static var previews: some View {
        ContentView(brackets: brackets)
    }
}

Step 4: Cells, lines, and an algebra remider

You might be wondering: “This is nice, but so far it’s an appetizer, let’s get to the main course already”. We agree, but let us try to explain the concepts first, and then once we understand that, we can dive into the code.

In order to know how to draw the lines (which connect the cells), we need to set some ground rules (again).

Tournament Brackets in Swift UI - Connecting Cells

The first column cells shouldn’t have a left line (as they’re the first matches).

  • The last column cells shouldn’t have a right line (as there’s no matches after it).

  • Even number cells will have the right line pointing down.

  • Odd number cells will have the right line pointing up.

But how do we achieve the “compacting” and “decompacting” of cells when you scroll left and right?

Tournament Brackets in Swift UI - Compacting Illustration

Decide the height of the cell you can use for the cells in focus (in our case 100pt).

  • Multiply that height by 2 for each column to the right.

  • Divide the cell height by 2 for each column to the left.

  • Collapse the cells to the left so as to not look broken when their height is reduced.

In theory, with all of this knowledge, we should get cells and lines that look nice.

Let’s start inside BracketCell:

  • Add heightScalingExponent, isTopMatch, isCollapsed, isFirstColumn, and isLastColumn properties. They will help us decide how to draw the cell.

  • In our case, We’ve opted to not render the top and bottom labels if the cell is collapsed.

  • Implement the cell height we’ve spoken about 2 paragraphs above this one. If the exponent is positive, we’re multiplying it by 2, and if it's negative, we’re dividing it by 2.

  • Unless we’re in the first column, draw a straight line on the left of the cell.

  • We’re drawing the right line in all of the columns except the last one, since the last column should always contain only one match.

  • If the match in question is the top one, after we render a horizontal line, we should render a vertical line from the center, to the bottom of the cell. This is achieved using a Spacer on top of the line.

  • For bottom matches, we’re applying the same logic as above, just the vertical line is rendered above Spacer.

struct BracketCell: View {
    
    ...
    
    // 1
    let heightScalingExponent: Int
    let isTopMatch: Bool
    let isCollapsed: Bool
    let isFirstColumn: Bool
    let isLastColumn: Bool
    
    private var lineColor: Color {
        .black
    }
    
    // 2
    var body: some View {
        HStack(spacing: 0) {
            leftLineArea
            
            VStack(spacing: 0) {
                if !isCollapsed {
                    topLabelArea
                }
                
                team1ScoreArea
                team2ScoreArea
                
                if !isCollapsed {
                    touchForMoreInfoArea
                }
            }
            
            rightLineArea
        }
        .listRowSeparator(.hidden)
        .listRowInsets(EdgeInsets())
        
        // 3
        .frame(height: height)
        .transition(.opacity.combined(with: .scale(scale: 1, anchor: .top)))
    }
    
    private var height: CGFloat {
        100 * pow(2, CGFloat(heightScalingExponent))
    }
    
    // 4
    private var leftLineArea: some View {
        Group {
            if !isFirstColumn {
                Rectangle()
                    .foregroundColor(lineColor)
            } else {
                Spacer()
            }
        }
        .frame(width: UIScreen.main.bounds.width * 0.05, height: 2)
    }
    
    // 5
    private var rightLineArea: some View {
        Group {
            if !isLastColumn {
                rightLine
            } else {
                Spacer()
                    .frame(width: UIScreen.main.bounds.width * 0.05, height: 2)
            }
        }
    }
    
    private var rightLine: some View {
        HStack(spacing: 0) {
            rightHorizontalLine
            
            // 6
            if isTopMatch {
                topMatchRightVerticalLine
                
            // 7
            } else {
                bottomMatchRightVerticalLine
            }
        }
    }
    
    private var rightHorizontalLine: some View {
        Rectangle()
            .frame(width: UIScreen.main.bounds.width * 0.05, height: 2)
            .foregroundColor(lineColor)
    }
    
    private var topMatchRightVerticalLine: some View { //place vertical line in lower half of view
        VStack(spacing: 0) {
            Spacer()
                .frame(height: height / 2)
            Rectangle()
                .frame(width: 2, height: height / 2 + 2)
                .foregroundColor(lineColor)
        }
    }
    
    private var bottomMatchRightVerticalLine: some View { //place vertical line in upper half of view
        VStack(spacing: 0) {
            Rectangle()
                .frame(width: 2, height: height / 2 + 2)
                .foregroundColor(lineColor)
            Spacer()
                .frame(height: height / 2)
        }
    }
    
    ...
    
}

struct BracketCell_Previews: PreviewProvider {
    private static let previewMatchData = MatchData(team1: "Team 1", team2: "Team 2", team1Score: 2, team2Score: 1)
    
    static var previews: some View {
        BracketCell(matchData: previewMatchData,
                              heightScalingExponent: 0,
                              isTopMatch: true,
                              isCollapsed: false,
                              isFirstColumn: true,
                              isLastColumn: false)
    }
}

So, that’s how we render the BracketCells, but where do they get all of this data from? We have to pass this data from ContentView to BracketColumnView, and then to individual BracketCells.

First things first, prepare the BracketColumnView. In order to calculate all the necessary things, it’ll need some data.

  • Add columnIndex, focusedColumnIndex, and lastColumnIndex properties.

  • Change how we loop over matches, because we’re going to need the match index to calculate if the match is the top cell.

Update the data we pass to BracketCell:

  • Make heightScalingExponent equal to the difference between the column index, and the focused column index.

  • Collapse the cells left of the focused index.

struct BracketColumnView: View {

    ...

    // 1
    let columnIndex: Int
    let focusedColumnIndex: Int
    let lastColumnIndex: Int
    
    var body: some View {
        LazyVStack(spacing: 0) {
            // 2
            ForEach(0 ..< bracket.matches.count, id: \.self) { matchIndex in
                BracketCell(matchData: bracket.matches[matchIndex],
                                      // 3
                                      heightScalingExponent: columnIndex - focusedColumnIndex,
                                      isTopMatch: matchIndex % 2 == 0,
                                      // 4
                                      isCollapsed: columnIndex < focusedColumnIndex,
                                      isFirstColumn: columnIndex == 0,
                                      isLastColumn: columnIndex == lastColumnIndex)
            }
        }
    }
}

struct BracketColumnView_Previews: PreviewProvider {
    
    ...
    
    static var previews: some View {
        BracketColumnView(bracket: previewBracket,
                                    columnIndex: 0,
                                    focusedColumnIndex: 0,
                                    lastColumnIndex: 2)
            .padding(.horizontal)
    }
}

Now we only need to pass this data to BracketColumnView from ContentView, right?

We’re going to add some spice to the vertical ScrollView. Since we’ve decided to condense the cells once we scroll onto the column, we’re going to scroll the vertical ScrollView up so that the user doesn’t have to do it manually. Also, we’ll add some alignments to our snap scrolling so that we can see all of this in action better.

  • Let’s create a leadingOffsetXForCurrentColumn computed variable. It will house the calculation to offset the horizontal ScrollView a tiny bit to make the first column align left, center columns aligned center, and last column aligned to the right.

  • Wrap the entire body inside a ScrollViewReader, which will be used to scroll us to the top when we switch focused columns.

  • Add an anchor to scroll to.

  • Observe changes to the focusedColumnIndex and trigger the scroll to top when it changes.

  • Change how we loop over the brackets as we need the column index to pass down to BracketColumnView.

  • Pass down all of the new data to BracketColumnView.

struct ContentView: View {
    
    ...
    
    // 1
    private var offsetX: CGFloat {
        -CGFloat(focusedColumnIndex) * columnWidth + dragOffsetX + leadingOffsetXForCurrentColumn
    }
    
    private var leadingOffsetXForCurrentColumn: CGFloat {
        if focusedColumnIndex == 0 {
            return 0
        } else if focusedColumnIndex == numberOfColumns - 1 {
            return UIScreen.main.bounds.width * 0.10
        } else {
            return UIScreen.main.bounds.width * 0.05
        }

    }
    
    var body: some View {
        // 2
        ScrollViewReader { scrollViewProxy in
            ScrollView(.vertical, showsIndicators: false) {
                // 3
                EmptyView()
                    .id("scroll-to-top-anchor")
                ScrollView(.horizontal, showsIndicators: false) {
                    columns
                        .offset(x: offsetX)
                }
                .frame(width: UIScreen.main.bounds.size.width)
                .scrollDisabled(true)
                .gesture(DragGesture(minimumDistance: 12, coordinateSpace: .global)
                    .onChanged(updateCurrentOffsetX)
                    .onEnded(handleDragEnded)
                )
            }
            // 4
            .onChange(of: focusedColumnIndex) { _ in
                withAnimation {
                    scrollViewProxy.scrollTo("scroll-to-top-anchor")
                }
            }
        }
        
    }
    
    private var columns: some View {
        HStack(alignment: .top, spacing: 0) {
            
            // 5
            ForEach(0..<brackets.count, id: \.self) { columnIndex in
                
                //6
                BracketColumnView(bracket: brackets[columnIndex],
                                            columnIndex: columnIndex,
                                            focusedColumnIndex: focusedColumnIndex,
                                            lastColumnIndex: numberOfColumns - 1)
                    .frame(width: columnWidth)
            }
        }
        .frame(width: CGFloat(numberOfColumns) * columnWidth)
    }
    
    ...
    
}

This is how the app should look and behave after implementing all of the changes above:

Finishing Touches

Thank you for taking the time to read this blog. I hope you found the information useful and that it helped you solve your problem, or at least develop your skills in SwiftUI. Stay tuned for an upcoming bonus chapter, where we'll be making a scrollable column indicator, and a match display screen. Happy coding!

The full code is available on GitHub.

Bonus: Column indicator and match display

If you remember the final product, there was a view that displayed the column names. It was scrollable, and would highlight the current column name. It was also tappable and would animate transitioning to that column.

struct SelectedColumnIndicator: View {
    let columnNames: [String]
    
    // 1
    @Binding var focusedColumnIndex: Int
    
    var body: some View {
        // 2
        ScrollViewReader { scrollProxy in
            ScrollView(.horizontal, showsIndicators: false) {
                scrollViewContent
            }
            
            // 3
            .onChange(of: focusedColumnIndex) { newIndex in scroll(to: newIndex, using: scrollProxy)}
        }
    }

    var scrollViewContent: some View {
        HStack(spacing: 24) {
            
            // 4
            ForEach(0..<columnNames.count, id: \.self) { columnIndex in
                indicator(for: columnIndex)
            }
        }
        .padding(24)
    }
    
    // 5
    private func indicator(for columnIndex: Int) -> some View {
        Button(action: { didTapColumnIndicator(at: columnIndex) }) {
            Text(columnNames[columnIndex].uppercased())
                .font(.system(size: 24))
                .bold()
                .foregroundColor(columnIndex == focusedColumnIndex ? .black : .gray)
                .id(columnIndex)
        }
    }
    
    private func scroll(to index: Int, using scrollProxy: ScrollViewProxy) {
        withAnimation {
            scrollProxy.scrollTo(index, anchor: .center)
        }
    }
    
    // 6
    private func didTapColumnIndicator(at index: Int) {
        withAnimation {
            focusedColumnIndex = index
        }
    }
}

struct SelectedColumnIndicator_Previews: PreviewProvider {
    @State private static var focusedColumnIndex = 0
    
    static var previews: some View {
        SelectedColumnIndicator(columnNames: ["Eights", "Quarterfinals", "Semifinals", "Finals"], focusedColumnIndex: $focusedColumnIndex)
    }
}
  • We’re making focusedColumnIndex a @Binding so that we can change it from inside the view.

  • The ScrollViewReader is used to scroll the content to the focused index.

  • We’re observing the changes to focusedColumnIndex and then scrolling to the center of that indicator.

  • Create the indicators by looping over the column names.

  • Since we want to give the user an option of navigating this way, the indicator itself is a Button.

  • On tapping the indicator, simply set the focusedColumnIndex and step 3 will handle everything else for us.

Inside ContentView, replace the EmptyView with SelectedColumnIndicator, and pass in the required data.

struct ContentView: View {
   
    ...
   
    var body: some View {
        ScrollViewReader { scrollViewProxy in
            ScrollView(.vertical, showsIndicators: false) {
                
                // 1
                SelectedColumnIndicator(columnNames: brackets.map({ bracket in bracket.name }),
                                        focusedColumnIndex: $focusedColumnIndex)
                .id("scroll-to-top-anchor")
                
                ScrollView(.horizontal, showsIndicators: false) {
                    columns
                        .offset(x: offsetX)
                }
                .frame(width: UIScreen.main.bounds.size.width)
                .scrollDisabled(true)
                .gesture(DragGesture(minimumDistance: 12, coordinateSpace: .global)
                    .onChanged(updateCurrentOffsetX)
                    .onEnded(handleDragEnded)
                )
            }
            .onChange(of: focusedColumnIndex) { _ in
                withAnimation {
                    scrollViewProxy.scrollTo("scroll-to-top-anchor")
                }
            }
        }
        
    }
    
    ...
    
}

Now that’s done, we'll also add the option of tapping on a match to see details. Create MatchDetailsView that takes in a MatchData object as data. This view can be styled to your liking, mine can be seen below.

struct MatchDetailsView: View {
    let matchData: MatchData
    
    var body: some View {
        VStack(spacing: 16) {
            teamNamesArea
            teamScoresArea
        }
    }
    
    private var teamNamesArea: some View {
        HStack(spacing: 40) {
            Text(matchData.team1.uppercased())
                .bold()
                .opacity(matchData.team1Score > matchData.team2Score ? 1 : 0.3)
            
            Text("vs")
                .font(.system(size: 16))
            
            Text(matchData.team2.uppercased())
                .bold()
                .opacity(matchData.team2Score > matchData.team1Score ? 1 : 0.3)
        }
        .font(.system(size: 32))
    }
    
    private var teamScoresArea: some View {
        HStack(spacing: 20) {
            Text("\(matchData.team1Score)".uppercased())
                .bold()
                .opacity(matchData.team1Score > matchData.team2Score ? 1 : 0.3)
            
            Text(":")
                .font(.system(size: 16))
            
            Text("\(matchData.team2Score)".uppercased())
                .bold()
                .opacity(matchData.team2Score > matchData.team1Score ? 1 : 0.3)
        }
        .font(.system(size: 48))
    }
}

struct MatchDetailsView_Previews: PreviewProvider {
    static var previews: some View {
        MatchDetailsView(matchData: .init(team1: "Team 1", team2: "Team 2", team1Score: 3, team2Score: 0))
    }
}

Inside BracketColumnView:

  • Add a property called didTapCell which is a callback that takes the MatchData as data.

  • Add a tapGesture on the BracketCell, and inside the callback, call didTapCell and pass in that cell's MatchData.

struct BracketColumnView: View {

    ...
    
    // 1
    let didTapCell: (MatchData) -> Void
    
    var body: some View {
        LazyVStack(spacing: 0) {
            ForEach(0 ..< bracket.matches.count, id: \.self) { matchIndex in
                BracketCell(matchData: bracket.matches[matchIndex],
                                      heightScalingExponent: columnIndex - focusedColumnIndex,
                                      isTopMatch: matchIndex % 2 == 0,
                                      isCollapsed: columnIndex < focusedColumnIndex,
                                      isFirstColumn: columnIndex == 0,
                                      isLastColumn: columnIndex == lastColumnIndex)
                
                // 2
                .onTapGesture {
                    didTapCell(bracket.matches[matchIndex])
                }
            }
        }
    }
}

struct BracketColumnView_Previews: PreviewProvider {
    
    ...
    
    static var previews: some View {
        BracketColumnView(bracket: previewBracket,
                                    columnIndex: 0,
                                    focusedColumnIndex: 0,
                                    lastColumnIndex: 2,
                                    didTapCell: { _ in })
            .padding(.horizontal)
    }
}

Then inside ContentView:

  • Add a @State property called presentingMatchDetails which will store the currently presented match details.

  • Add a .sheet which will present the MatchDetailsView on the vertical ScrollView.

struct ContentView: View {
    
    ...
    
    // 1
    @State private var presentingMatchDetails: MatchData?
    
   ...
    
    var body: some View {
        ScrollViewReader { scrollViewProxy in
            ScrollView(.vertical, showsIndicators: false) {
                SelectedColumnIndicator(columnNames: brackets.map({ bracket in bracket.name }),
                                        focusedColumnIndex: $focusedColumnIndex)
                .id("scroll-to-top-anchor")
                
                ScrollView(.horizontal, showsIndicators: false) {
                    columns
                        .offset(x: offsetX)
                }
                .frame(width: UIScreen.main.bounds.size.width)
                .scrollDisabled(true)
                .gesture(DragGesture(minimumDistance: 12, coordinateSpace: .global)
                    .onChanged(updateCurrentOffsetX)
                    .onEnded(handleDragEnded)
                )
            }
            
            // 2
            .sheet(item: $presentingMatchDetails, onDismiss: { presentingMatchDetails = nil }) { details in
                MatchDetailsView(matchData: details)
                    .padding(.horizontal)
                    .presentationDetents([.medium])
                    .presentationDragIndicator(.visible)
            }
            .onChange(of: focusedColumnIndex) { _ in
                withAnimation {
                    scrollViewProxy.scrollTo("scroll-to-top-anchor")
                }
            }
        }
        
    }
    
    private var columns: some View {
        HStack(alignment: .top, spacing: 0) {
            ForEach(0..<brackets.count, id: \.self) { columnIndex in
                BracketColumnView(bracket: brackets[columnIndex],
                                            columnIndex: columnIndex,
                                            focusedColumnIndex: focusedColumnIndex,
                                            lastColumnIndex: numberOfColumns - 1,
                                            
                                            // 3
                                            didTapCell: { matchData in presentingMatchDetails = matchData }
                )
                    .frame(width: columnWidth)
            }
        }
        .frame(width: CGFloat(numberOfColumns) * columnWidth)
    }
    
    ...
    
}

That’s all folks 👋

Related Articles

Join our newsletter

Like what you see? Why not put a ring on it. Or at least your name and e-mail.

Have a project on the horizon?

Let's Talk