Last active
December 2, 2025 02:04
-
-
Save slangley/7175afc3aaafcdfd3878ce676f088190 to your computer and use it in GitHub Desktop.
Exploring how to make an "action" button on the TabView in SwiftUI with Liquid Glass. (Screenshot in comment)
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
| // | |
| // GlassTabBar.swift | |
| // Soothe | |
| // | |
| // Created by Sean Langley on 2025-11-28. | |
| // | |
| import SwiftUI | |
| struct TabItem: Identifiable { | |
| let id = UUID() | |
| let icon: String | |
| let title: String | |
| let tag: Int | |
| } | |
| struct GlassTabBar: View { | |
| var namespace: Namespace.ID | |
| @Binding var selectedTab: Int | |
| let items: [TabItem] | |
| @Namespace private var animation | |
| @State private var touchedTab: Int? = nil | |
| @State private var isTouching: Bool = false | |
| var body: some View { | |
| HStack(spacing: 0) { | |
| ForEach(items) { item in | |
| TabBarButton( | |
| icon: item.icon, | |
| title: item.title, | |
| isSelected: selectedTab == item.tag, | |
| isTouched: touchedTab == item.tag, | |
| isTouching: isTouching, | |
| namespace: animation, | |
| itemTag: item.tag, | |
| onTouchBegan: { | |
| isTouching = true | |
| withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { | |
| touchedTab = item.tag | |
| } | |
| }, | |
| onTouchEnded: { | |
| if let touched = touchedTab { | |
| withAnimation(.spring(response: 0.4, dampingFraction: 0.75)) { | |
| selectedTab = touched | |
| } | |
| } | |
| isTouching = false | |
| touchedTab = nil | |
| } | |
| ) | |
| .frame(maxWidth: .infinity) | |
| .overlay { | |
| // Indicator positioned absolutely behind each button | |
| HStack { | |
| if (isTouching && touchedTab == item.tag) || (!isTouching && selectedTab == item.tag) { | |
| GlassTabIndicator(isPressed: isTouching) | |
| } | |
| } | |
| .allowsHitTesting(false) | |
| } | |
| } | |
| } | |
| .padding(.vertical, 6) | |
| .glassEffect(.regular)//.glassEffectUnion(id: "tabbar", namespace: namespace) | |
| .padding(.horizontal, 24) | |
| .padding(.bottom, 16) | |
| } | |
| } | |
| struct TabBarButton: View { | |
| let icon: String | |
| let title: String | |
| let isSelected: Bool | |
| let isTouched: Bool | |
| let isTouching: Bool | |
| let namespace: Namespace.ID | |
| let itemTag: Int | |
| let onTouchBegan: () -> Void | |
| let onTouchEnded: () -> Void | |
| var body: some View { | |
| VStack(spacing: 4) { | |
| Image(systemName: icon) | |
| .font(.system(size: 20, weight: (isSelected || isTouched) ? .semibold : .regular)) | |
| .foregroundStyle((isSelected || isTouched) ? .primary : .secondary) | |
| .foregroundColor(isSelected ? .accentColor : .primary) | |
| Text(title) | |
| .font(.caption2) | |
| .fontWeight((isSelected || isTouched) ? .semibold : .regular) | |
| .foregroundStyle((isSelected || isTouched) ? .primary : .secondary) | |
| .foregroundColor(isSelected ? .accentColor : .primary) | |
| } | |
| .frame(maxWidth: .infinity) | |
| .contentShape(Rectangle()) | |
| .gesture( | |
| DragGesture(minimumDistance: 0) | |
| .onChanged { _ in | |
| if !isTouching || !isTouched { | |
| onTouchBegan() | |
| } | |
| } | |
| .onEnded { _ in | |
| onTouchEnded() | |
| } | |
| ) | |
| } | |
| } | |
| struct GlassTabIndicator: View { | |
| var isPressed: Bool | |
| var body: some View { | |
| RoundedRectangle(cornerRadius: 22) | |
| .frame(width: 70, height: 44) | |
| .scaleEffect(isPressed ? 1.5 : 1.0) | |
| .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed) | |
| .background(Color.black.opacity(0.1)) | |
| } | |
| } | |
| // MARK: - Example Usage | |
| struct GlassTabBarExample: View { | |
| @Namespace var namespace | |
| @State private var selectedTab = 0 | |
| @State private var buttonPressed = false | |
| let tabItems = [ | |
| TabItem(icon: "house.fill", title: "Home", tag: 0), | |
| TabItem(icon: "heart.fill", title: "Favorites", tag: 1), | |
| TabItem(icon: "chart.line.uptrend.xyaxis", title: "Stats", tag: 2), | |
| TabItem(icon: "person.fill", title: "Profile", tag: 3) | |
| ] | |
| var body: some View { | |
| ZStack(alignment: .bottom) { | |
| Rectangle().background(LinearGradient(colors: [.blue, .purple], startPoint: .top, endPoint: .bottom)).frame(maxWidth: .infinity, maxHeight: .infinity) | |
| // Tab content that extends behind the tab bar | |
| TabView(selection: $selectedTab) { | |
| HomeTabContent() | |
| .tag(0) | |
| FavoritesTabContent() | |
| .tag(1) | |
| StatsTabContent() | |
| .tag(2) | |
| ProfileTabContent() | |
| .tag(3) | |
| } | |
| .tabViewStyle(.page(indexDisplayMode: .never)) | |
| // Floating glass tab bar overlaid on top | |
| GlassEffectContainer(spacing: 40) { | |
| VStack(spacing:-10) { | |
| Button { | |
| buttonPressed.toggle() | |
| } label: { | |
| Image(systemName: "plus").padding() | |
| }.glassEffect(.regular).frame(width:50,height:50).glassEffectUnion(id: "tabbar", namespace: namespace) | |
| GlassTabBar(namespace: namespace, selectedTab: $selectedTab, items: tabItems) | |
| } | |
| } | |
| } | |
| .ignoresSafeArea(.container, edges: .bottom) | |
| .sheet(isPresented: $buttonPressed) { | |
| Text("Alert presented. Swipe down to dismiss.") | |
| } | |
| } | |
| } | |
| // MARK: - Demo Tab Content Views | |
| struct HomeTabContent: View { | |
| var body: some View { | |
| VStack { | |
| Image(systemName: "house.fill") | |
| .font(.system(size: 60)) | |
| .foregroundStyle(.tint) | |
| Text("Home") | |
| .font(.title) | |
| .fontWeight(.bold) | |
| } | |
| } | |
| } | |
| struct FavoritesTabContent: View { | |
| var body: some View { | |
| VStack { | |
| Image(systemName: "heart.fill") | |
| .font(.system(size: 60)) | |
| .foregroundStyle(.red) | |
| Text("Favorites") | |
| .font(.title) | |
| .fontWeight(.bold) | |
| } | |
| } | |
| } | |
| struct StatsTabContent: View { | |
| var body: some View { | |
| VStack { | |
| Image(systemName: "chart.line.uptrend.xyaxis") | |
| .font(.system(size: 60)) | |
| .foregroundStyle(.green) | |
| Text("Statistics") | |
| .font(.title) | |
| .fontWeight(.bold) | |
| } | |
| } | |
| } | |
| struct ProfileTabContent: View { | |
| var body: some View { | |
| VStack { | |
| Image(systemName: "person.fill") | |
| .font(.system(size: 60)) | |
| .foregroundStyle(.orange) | |
| Text("Profile") | |
| .font(.title) | |
| .fontWeight(.bold) | |
| } | |
| } | |
| } | |
| #Preview("Full Screen", traits: .sizeThatFitsLayout) { | |
| GlassTabBarExample() | |
| } | |
Author
slangley
commented
Dec 2, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment