Skip to content

Instantly share code, notes, and snippets.

@kieranb662
Created July 24, 2021 21:35
Show Gist options
  • Save kieranb662/a6ef22c0fbf8dcfec2452227897f99f0 to your computer and use it in GitHub Desktop.
Save kieranb662/a6ef22c0fbf8dcfec2452227897f99f0 to your computer and use it in GitHub Desktop.
A Fixed Tab Bar component made in SwiftUI. Created as an example of a component that supports styles and library content. Check the bottom In the preview to see How to use.
// Swift toolchain version 5.0
// Running macOS version 12.0
// Created on 7/24/21.
//
// Author: Kieran Brown
//
import SwiftUI
// MARK: -Component-
struct FixedTabBar: View {
@Environment(\.fixedTabBarStyle) private var style: FixedTabBarStyle
private let options: [String]
private let optionCount: CGFloat
@Binding private var selection: String
// Store the previous value to keep bar offset consistent until gesture ends and the selection is finalized
@State private var preGestureSelection: String? = nil
@State private var barDragOffset: CGFloat = 0
var body: some View {
GeometryReader { proxy in
HStack(spacing: 0) {
ForEach(options, id: \.self, content: { Option($0, proxy: proxy) })
}
.frame(height: style.height)
.overlay(Bar(proxy: proxy), alignment: .bottomLeading)
}
.frame(height: style.height)
}
private func Option(_ option: String, proxy: GeometryProxy) -> some View {
HStack(spacing: 0) {
Spacer(minLength: style.labelPadding)
Text(option)
.fixedSize()
.foregroundColor(option == selection ? style.labelSelectedColor : style.labelColor)
.contentShape(Rectangle())
.onTapGesture(perform: { optionWasTappedHandler(option) })
.animation(.spring())
Spacer(minLength: style.labelPadding)
}
.frame(width: proxy.size.width/optionCount)
}
private func Bar(proxy: GeometryProxy) -> some View {
Rectangle()
.foregroundColor(style.barFill)
.frame(width: proxy.size.width/optionCount, height: style.barHeight)
.frame(height: style.hitBoxHeight, alignment: .bottom) // The touchable height is larger.
.contentShape(Rectangle()) // Use Content shape to make the entire area of the ZStack register with hit detection.
.offset(x: barOffset(proxy: proxy))
.gesture(
DragGesture()
.onChanged({ barDragDidChangeHandler($0, proxy: proxy) })
.onEnded({ barDragDidEndHandler($0.location.x, proxy: proxy) })
)
}
// MARK: - Init
/// Creates a new `FixedTabBar` that allows selecting options by tapping or by dragging the bottom bar to the option.
/// - parameters:
/// - options: The selectable options that your `FixedTabBar` should display. **Note**: the number of options must be greater than one and less than 6 to ensure the view is selectable and the options fit respectively.
/// - selection: A binding to the currently selected tab option.
/// - note this component has a fixed layout and is not embedded into a scrollView. That implies that the options will recieve as much horizontal width as the parent container will provide. Your options should be limited to only a few and the names should be concise.
init?(options: [String], selection: Binding<String>) {
guard options.count > 1 && options.count < 6 else {
return nil
}
self.optionCount = CGFloat(options.count)
self.options = options
self._selection = selection
}
}
// MARK: -Style-
// Step 1. Create a struct to store all of our stylable properties
struct FixedTabBarStyle {
var labelPadding: CGFloat = 16
var height: CGFloat = 48
var barHeight: CGFloat = 4
var hitBoxHeight: CGFloat = 20
var labelColor: Color? = Color.gray
var labelSelectedColor: Color? = nil
var barFill: Color? = Color.blue
}
// Step 2. Make a Key to store our style in the environment values
struct FixedTabBarStyleKey: EnvironmentKey {
static let defaultValue = FixedTabBarStyle()
}
// Step 3. Add the style as a property in the EnvironmentValues.
extension EnvironmentValues {
var fixedTabBarStyle: FixedTabBarStyle {
get { self[FixedTabBarStyleKey.self] }
set { self[FixedTabBarStyleKey.self] = newValue }
}
}
// Step 4. Add a convenient way to apply styles using dot syntax
extension View {
func fixedTabBarStyle(_ style: FixedTabBarStyle) -> some View {
return environment(\.fixedTabBarStyle, style)
}
}
// MARK: -Utility and Calculations-
private extension FixedTabBar {
func barOffset(proxy: GeometryProxy) -> CGFloat {
let optionWidth = proxy.size.width/optionCount
// If we moving the bar then continue to calculate the offset
guard let preGestureSelection = preGestureSelection, barDragOffset != 0
else { // else just place the bar underneath the currently selected option
let index = options.firstIndex(of: selection) ?? 0
return CGFloat(index) * optionWidth
}
let index = options.firstIndex(of: preGestureSelection) ?? 0
return barDragOffset + CGFloat(index) * optionWidth
}
func optionWasTappedHandler(_ option: String) {
if option != selection {
withAnimation { selection = option }
playSelectionHaptic()
}
}
func barDragDidChangeHandler(_ dragValues: DragGesture.Value, proxy: GeometryProxy) {
if preGestureSelection == nil { preGestureSelection = selection }
barDragOffset = dragValues.translation.width
checkDragForSelectionChanges(dragValues.location.x, proxy: proxy)
}
func barDragDidEndHandler(_ location: CGFloat, proxy: GeometryProxy) {
withAnimation(.spring()) {
preGestureSelection = nil
barDragOffset = 0
}
checkDragForSelectionChanges(location, proxy: proxy)
}
// Simple depiction of the critical points on the tabBar. The First option closest to the
// leading edge has an unbounded lower range limit. So any location value to the left of the
// critical point is considered to select Option 1. The Last option furthest to the trailing
// edge has an unbounded upper range limit. So any location further to the right of the last
// critical point is considered to select the final option. Any location between critical points
// is assumed to select the option it lies within.
//
// ====================================== DIAGRAM ==========================================
//
// critical 1 critical 2
// padding | | padding
// leading |~~~~~~~~~~[ Option 1 ]•[ Option 2 ]•[ Option 3 ]~~~~~~~~~~| trailing
// <------- First Option -----------|Second Option |------------Third Option--------->
//
//
// Critical(n) = padding + optionWidth * n
func checkDragForSelectionChanges(_ location: CGFloat, proxy: GeometryProxy) {
let optionWidth = proxy.size.width/optionCount
// get indicies of critical points
for index in 1..<options.count
// Where the drag location is less than the critical point position
where location < optionWidth * CGFloat(index) {
// if the corresponding option is not selected then select it.
if selection != options[index-1] {
withAnimation { selection = options[index-1] }
playSelectionHaptic()
}
break // We found our option, break the loop to prevent matching options further to the right.
}
// Check if drag location is to the right of the last critical point.
if location > optionWidth * CGFloat(options.count-1),
let option = options.last,
option != selection {
withAnimation { selection = option }
playSelectionHaptic()
}
}
func playSelectionHaptic() {
let generator = UISelectionFeedbackGenerator()
generator.selectionChanged()
}
}
// MARK: -LibraryContent-
struct FixedTabBar_LibraryContent: LibraryContentProvider {
var views: [LibraryItem] {
LibraryItem(FixedTabBar(options: ["Photos", "Videos", "Music"],
selection: .constant("Videos")),
title: "Fixed Tab Bar",
category: .control)
}
func modifiers(base: FixedTabBar) -> [LibraryItem] {
[LibraryItem(base.fixedTabBarStyle(FixedTabBarStyle(labelColor: Color.black,
labelSelectedColor: Color.purple,
barFill: Color.purple)),
title: "Fixed Tab Bar Style",
category: .control)]
}
}
// MARK: -Previews-
struct FixedTabBar_Previews: PreviewProvider {
static var previews: some View {
Group {
FixedTabBar(options: ["Photos", "Videos", "Music"],
selection: .constant("Videos"))?
.previewDisplayName("Default Style")
FixedTabBar(options: ["Photos", "Videos", "Music"],
selection: .constant("Videos"))?
.fixedTabBarStyle(FixedTabBarStyle(labelColor: Color.black,
labelSelectedColor: Color.purple,
barFill: Color.purple))
.previewDisplayName("Custom Style")
FixedTabBar(options: ["Photos", "Videos", "Music"],
selection: .constant("Videos"))?
.font(.system(.headline, design: .rounded))
.previewDisplayName("Using the font modifier")
FixedTabBar(options: ["Photos", "Videos", "Music"],
selection: .constant("Videos"))?
.font(.system(.headline, design: .rounded))
.background(Color(white: 0.9))
.previewDisplayName("With Background")
}
.padding(.vertical)
.previewLayout(.sizeThatFits)
}
}
@kieranb662
Copy link
Author

Screen Shot 2021-07-24 at 5 36 13 PM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment