02
Apr
2021
ScrollViewReader + onChange = Responsive Tabs
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:
- Follow the guidelines from the "scrolling programatically" recipe and wrap the scrollable content in a
ScrollViewReader
. Also, make sure that every tab has anid
. Since the tab titles are strings, which are alreadyHashable
, we can just use those (providing that we don't have duplicate tab titles). - Add the
onChange
modifier to the scrollable content, and have it trigger wheneverselection
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 currentScrollView
'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
}
}
}