import SwiftUI
// PillPreference - allows us to track child view position from parent view.
struct PillPreferenceData {
let index: Int
let bounds: Anchor<CGRect>
}
struct PillPreferenceKey: PreferenceKey {
typealias Value = [PillPreferenceData]
static var defaultValue: [PillPreferenceData] = []
static func reduce(value: inout [PillPreferenceData], nextValue: () -> [PillPreferenceData]) {
value.append(contentsOf: nextValue())
}
}
// Just a simple Tab view with some text and an image
struct TabContent: View {
var image: String
var text: String
var body: some View {
HStack {
VStack {
Text(image)
.font(.system(size: 60))
.frame(width: 100, height: 100)
.background(Color(UIColor.systemTeal))
.cornerRadius(1000)
Text(text)
.fontWeight(.bold)
.padding(.top, 20)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.opacity(0.2))
.cornerRadius(25)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(15)
}
}
// View for individual items in a tab controller
struct Pill: View {
@Binding var index: Int
@Binding var tapped: Bool
var key: Int
var label: String
var body: some View {
Text(label)
.fontWeight(.medium)
.foregroundColor(.white)
.padding(.vertical, 10)
.padding(.horizontal, 32)
.clipShape(Capsule())
.anchorPreference(key: PillPreferenceKey.self, value: .bounds, transform: { [PillPreferenceData(index: self.key, bounds: $0)] })
.onTapGesture {
self.index = key
self.tapped = true
}
}
}
// Parent View that ties everything together
struct SlideTabsView: View {
@State private var index: Int = 0
@State private var tabsIndex: Int = 0
@State private var tapped: Bool = false
var body: some View {
ZStack {
TabView(selection: self.$tabsIndex) {
TabContent(image: "👋", text: "Tab One").tag(0)
TabContent(image: "👐", text: "Tab Two").tag(1)
TabContent(image: "😺", text: "Tab Three").tag(2)
}
.animation(.default)
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
VStack {
Spacer()
ZStack {
HStack(spacing: 0) {
Pill(index: self.$index, tapped: $tapped, key: 0, label: "One")
Spacer()
Pill(index: self.$index, tapped: $tapped, key: 1, label: "Two")
Spacer()
Pill(index: self.$index, tapped: $tapped, key: 2, label: "Three")
}
.padding(.vertical, 8)
.padding(.horizontal, 8)
.background(Color.black.opacity(0.2))
.cornerRadius(80)
.padding(.horizontal, 16)
.backgroundPreferenceValue(PillPreferenceKey.self) { preferences in
GeometryReader { geometry in
self.createBackground(geometry, preferences)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
}.padding(.bottom, 30)
}
}
.onChange(of: index) { _ in
if self.index != self.tabsIndex {
self.tabsIndex = self.index
}
}
.onChange(of: tabsIndex) { _ in
if !self.tapped {
self.index = self.tabsIndex
}
}
.onChange(of: tapped) { _ in
// This function makes sure that after Pill is tapped it is not affected by
// tabsIndex animation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.tapped = false
}
}
}
// Creates movable background for Pills
func createBackground(_ geometry: GeometryProxy, _ preferences: [PillPreferenceData]) -> some View {
let p = preferences.first(where: { $0.index == self.index })
let bounds = p != nil ? geometry[p!.bounds] : .zero
return RoundedRectangle(cornerRadius: 80)
.foregroundColor(Color.blue)
.frame(width: bounds.size.width, height: bounds.size.height)
.fixedSize()
.offset(x: bounds.minX, y: bounds.minY)
.animation(.linear(duration: 0.2))
}
}
Custom Tabs view with animated controller
by Kane
This component was inspired by @Guthr_nj's twitter post.
Essentially this is a standard tabs view with custom animated controls.
To accomplish this, component uses Preferences modifier that allows parent view to determine where on the screen child views are located and through that position animated background in a correct place.
Essentially this is a standard tabs view with custom animated controls.
To accomplish this, component uses Preferences modifier that allows parent view to determine where on the screen child views are located and through that position animated background in a correct place.