Skip to content

Instantly share code, notes, and snippets.

@gtokman
Created May 25, 2025 18:25
Show Gist options
  • Save gtokman/dd222f009579829ac1627c51457ea8ec to your computer and use it in GitHub Desktop.
Save gtokman/dd222f009579829ac1627c51457ea8ec to your computer and use it in GitHub Desktop.
//
// ContentView.swift
// MessageMenu
//
// Created by Gary Tokman on 5/25/25.
//
import SwiftUI
struct ContentView: View {
@State var config: MenuConfig = .init(symbolImage: "plus")
var body: some View {
CustomMenuView(config: $config) {
NavigationStack {
ScrollView {
}
.navigationTitle ("Messages")
.safeAreaInset(edge: .bottom) {
BottomBar()
}
}
} actions: {
MenuAction(symbolImage: "camera", title: "Camera")
MenuAction(symbolImage: "photo.on.rectangle.angled", title: "Photos")
MenuAction(symbolImage: "face.smiling", title: "Genmoji")
MenuAction(symbolImage: "waveform", title: "Audio")
MenuAction(symbolImage: "apple.logo", title: "App Store")
MenuAction(symbolImage: "video.badge.waveform", title: "Facetime")
MenuAction(symbolImage: "rectangle.and.text.magnifyingglass", title: "#Images")
MenuAction(symbolImage: "suit.heart", title: "Digital Touch" )
MenuAction(symbolImage: "location", title: "Location")
MenuAction(symbolImage: "music.note", title: "Music" )
}
}
/// Custom Bottom Bar
@ViewBuilder
func BottomBar() -> some View {
HStack(spacing: 12) {
// Custom Menu Source Button
MenuSourceButton(config: $config) {
Image (systemName: "plus")
.frame(width: 35, height: 35)
.background {
Circle()
.fill(.gray.opacity(0.25))
.background(.background, in: .circle)
}
} onTap: {
print("tap")
}
TextField( "Text Message", text: .constant(""))
.padding(.vertical, 8)
.padding(.horizontal, 15)
.background {
Capsule()
.stroke(.gray.opacity(0.3), lineWidth: 1.5)
}
}
.padding(.horizontal)
.padding(.bottom)
}
}
/// Customized Source Button
struct MenuSourceButton<Content: View>: View {
@Binding var config: MenuConfig
@ViewBuilder var content: Content
var onTap: () -> Void
var body: some View {
content
.contentShape(.rect)
.onTapGesture {
onTap()
config.sourceView = .init(content)
config.showMenu.toggle()
}
// save source location
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .global)
} action: { newValue in
config.sourceLocation = newValue
}
.opacity(config.hideSouceView ? 0.01 : 1)
}
}
/// Act's as a Wrapper to show menu view on top of the wrapped view
struct CustomMenuView<Content: View>: View {
@Binding var config: MenuConfig
@ViewBuilder var content: Content
@MenuActionBuilder var actions: [MenuAction]
@State var isAnimating = false
@State var isAnimatingLabels = false
// reset when menu closed
@State private var activeActionID: UUID? = nil
var body: some View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
/// Bluured Overlay
Rectangle()
.fill(.bar)
.ignoresSafeArea()
.opacity(isAnimating ? 1 : 0)
.allowsHitTesting(false)
}
.overlay {
if isAnimating {
/// Instead of using withAnimation completion callback, I'm using onDisappear modifier
// to know when the animation get's completed!
Rectangle()
.foregroundStyle(.clear)
.contentShape(.rect)
.onDisappear {
config.hideSouceView = false
activeActionID = actions.first?.id
}
}
}
.overlay {
GeometryReader {
MenuScrollView($0)
if config.hideSouceView {
config.sourceView
.scaleEffect(isAnimating ? 15 : 1, anchor: .bottom)
.offset(x: config.sourceLocation.minX, y: config.sourceLocation.minY)
.opacity(isAnimating ? 0.25 : 1)
.blur(radius: isAnimating ? 130 : 0)
.ignoresSafeArea()
.allowsHitTesting(false)
}
}
.opacity(config.hideSouceView ? 1 : 0)
}
.onChange(of: config.showMenu) { oldValue, newValue in
if newValue {
config.hideSouceView = true
}
withAnimation(.smooth(duration: 0.45)) {
isAnimating = newValue
}
withAnimation(.easeInOut(duration: newValue ? 0.35 : 0.15)) {
isAnimatingLabels = newValue
}
}
}
/// Menu Scroll View
@ViewBuilder
func MenuScrollView(_ proxy: GeometryProxy) -> some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 0) {
ForEach(actions) { action in
MenuActionView(action)
}
}
.scrollTargetLayout()
.padding(.horizontal, 25)
.frame(maxWidth: .infinity, alignment: .leading)
/// For background tap to dismiss the menu view
.background {
Rectangle ()
.foregroundStyle(.clear)
.frame(width: proxy.size.width, height: proxy.size.height + proxy.safeAreaInsets.top + proxy.safeAreaInsets.bottom)
.contentShape(.rect)
.onTapGesture {
guard config.showMenu else { return }
config.showMenu = false
}
/// Sticking to the top!
.visualEffect { content, proxy in
content
.offset(
x: -proxy.frame(in: .global).minX,
y: -proxy.frame(in: .global).minY
)
}
}
}
.safeAreaPadding(.vertical, 20)
.safeAreaPadding(.top, (proxy.size.height - 70) / 2)
.scrollPosition(id: $activeActionID, anchor: .top)
.scrollIndicators(.hidden)
.allowsHitTesting(config.showMenu)
}
/// Menu Action View
@ViewBuilder
func MenuActionView(_ action: MenuAction) -> some View {
let sourceLocation = config.sourceLocation
HStack(spacing: 20) {
Image(systemName: action.symbolImage)
.font(.title3)
.frame(width: 40, height: 40)
.background {
Circle()
.fill(.background)
.shadow(radius: 1.5)
}
.scaleEffect(isAnimating ? 1 : 0.6)
.opacity(isAnimating ? 1 : 0)
.blur(radius: isAnimating ? 0 : 4)
Text(action.title)
.fontWeight(.medium)
.font(.system(size: 19))
.lineLimit (1)
.scaleEffect(isAnimatingLabels ? 1 : 0.6)
.opacity(isAnimatingLabels ? 1 : 0)
.blur(radius: isAnimatingLabels ? 0 : 4)
Spacer()
}
.visualEffect({ [isAnimating] content, proxy in
content
/// Making all the action to be placed at the source button location
.offset(
x: isAnimating ? 0 : sourceLocation.minX - proxy.frame(in: .global).minX,
y: isAnimating ? 0 : sourceLocation.minY - proxy.frame(in: .global).minY
)
})
.frame(height: 70)
.containerShape(.rect)
.onTapGesture {
print("yo", action.title)
action.action()
}
}
}
#Preview {
ContentView()
}
/// Menu Config
struct MenuConfig {
let symbolImage: String
var sourceLocation: CGRect = .zero
var showMenu: Bool = false
// storing source for scaling
var sourceView: AnyView = .init(EmptyView())
var hideSouceView: Bool = false
}
struct MenuAction: Identifiable {
let id: UUID = .init()
let symbolImage: String
let title: String
let action: () -> Void = { }
}
@resultBuilder
struct MenuActionBuilder {
static func buildBlock(_ components: MenuAction...) -> [MenuAction] {
components.compactMap({ $0 })
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment