Created
July 24, 2021 21:35
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} |
Author
kieranb662
commented
Jul 24, 2021
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment