Reading time: 2 min

This recipe shows how to combine ScrollViewReader and onChange(of:perform:) to jump to the selected item in a scrollable view. We'll use this method to update our top tabs and make them fully visible when selected.

You can see the end result here:

Both the ScrollViewReader and onChange were only introduced in iOS 14, so this code won't work on earlier iOS versions.

Here's the breakdown of what we'll do:

  1. Follow the guidelines from the "scrolling programatically" recipe and wrap the scrollable content in a ScrollViewReader. Also, make sure that every tab has an id. Since the tab titles are strings, which are already Hashable, we can just use those (providing that we don't have duplicate tab titles).
  2. Add the onChange modifier to the scrollable content, and have it trigger whenever selection is changed, so that it scrolls to the currently selected tab. Note that this will only scroll if the selected tab is partly outside the current ScrollView's viewport.

Here's the code:

struct Tabs<Label: View>: View {
  @Binding var tabs: [String] // The tab titles
  @Binding var selection: Int // Currently selected tab
  let underlineColor: Color // Color of the underline of the selected tab
  // Tab label rendering closure - provides the current title and if it's the currently selected tab
  let label: (String, Bool) -> Label

  var body: some View {
    // Pack the tabs horizontally and allow them to be scrolled
    ScrollView(.horizontal, showsIndicators: false) {
      ScrollViewReader { reader in // 1
        HStack(alignment: .center, spacing: 30) {
          ForEach(tabs, id: \.self) {
            self.tab(title: $0)
          }
        }.padding(.horizontal, 3) // Tuck the out-most elements in a bit
        .onChange(of: selection) { _ in // 2
          reader.scrollTo(tabs[selection])
        }
      }
    }
  }

  private func tab(title: String) -> some View {
    let index = self.tabs.firstIndex(of: title)!
    let isSelected = index == selection
    return Button(action: {
      // Allows for animated transitions of the underline,
      // as well as other views on the same screen
      withAnimation {
         self.selection = index
      }
    }) {
      label(title, isSelected)
        .overlay(Rectangle() // The line under the tab
          .frame(height: 2)
           // The underline is visible only for the currently selected tab
          .foregroundColor(isSelected ? underlineColor : .clear)
          .padding(.top, 2)
          // Animates the tab selection
          .transition(.move(edge: .bottom)), alignment: .bottomLeading)
          .id(title) // 1
    }
  }
}

Next Post Previous Post