app-patterns
4
总安装量
3
周安装量
#48844
全站排名
安装命令
npx skills add https://github.com/makgunay/claude-swift-skills --skill app-patterns
Agent 安装分布
opencode
3
claude-code
3
codex
3
cursor
3
gemini-cli
2
github-copilot
2
Skill 文档
App UI Patterns
Reusable UI patterns for utility apps â prompt managers, clipboard tools, text expanders, and launchers. All patterns use Liquid Glass and assume macOS 26+ / iOS 26+.
Pattern Index
| Pattern | When to Use | Key Skill Dependencies |
|---|---|---|
| Spotlight Quick Access | Hotkey â search â select â action | global-hotkeys, appkit-bridge |
| Category Sidebar | Main app window, content browsing | swiftui-core |
| Floating Editor | Edit content in overlay panel | appkit-bridge |
| Compact Palette | Grid selection without search | liquid-glass |
| Settings Form | App preferences | macos-app-structure |
| Toast Notification | Ephemeral feedback after actions | â |
| Variable System | {{placeholder}} detection, input, highlighting |
â |
| Template Picker | Create from predefined templates | â |
| Rich Copy | Multi-format clipboard (plain + HTML + RTF) | pasteboard-textinsertion |
| Diff View | Version comparison | â |
Pattern 1: Spotlight-Style Quick Access
The core pattern for hotkey-triggered search â select â action.
See references/spotlight-panel.md for complete implementation.
Key Behaviors
- Search field auto-focuses on appear
- Arrow keys navigate results, Enter selects, Escape dismisses
- âEnter for alternate action (e.g., copy instead of paste)
- Selection morphs between rows via
.glassEffectID - Results scroll to keep selection visible
- Empty state shown when search has no matches
Structure
struct QuickAccessPanel: View {
@State private var query = ""
@State private var selectedIndex = 0
@FocusState private var searchFocused: Bool
@Namespace private var selection
var body: some View {
VStack(spacing: 0) {
// Search bar with glass
searchBar
.glassEffect(.regular, in: .rect(cornerRadius: 14))
// Results list with morphing selection
resultsList
.glassEffect(.clear, in: .rect(cornerRadius: 14))
}
.frame(width: 600)
.onAppear { searchFocused = true }
.onKeyPress { handleKeyPress($0) }
}
}
Keyboard Handling
func handleKeyPress(_ press: KeyPress) -> KeyPress.Result {
switch press.key {
case .upArrow:
selectedIndex = max(0, selectedIndex - 1)
return .handled
case .downArrow:
selectedIndex = min(filtered.count - 1, selectedIndex + 1)
return .handled
case .escape:
onDismiss()
return .handled
case .return where press.modifiers.contains(.command):
copyToClipboard(filtered[safe: selectedIndex])
return .handled
default:
return .ignored
}
}
Pattern 2: Category Sidebar
Three-column NavigationSplitView for the main app window.
struct MainView: View {
@State private var selectedCategory: Category?
@State private var selectedPrompt: Prompt?
var body: some View {
NavigationSplitView {
CategorySidebar(selection: $selectedCategory)
} content: {
PromptList(category: selectedCategory, selection: $selectedPrompt)
} detail: {
if let prompt = selectedPrompt {
PromptEditor(prompt: prompt)
} else {
ContentUnavailableView("Select a Prompt", systemImage: "text.quote")
}
}
.navigationSplitViewStyle(.balanced)
}
}
Sidebar with Glass Selection Morphing
struct CategorySidebar: View {
@Binding var selection: Category?
@Namespace private var categorySelection
var body: some View {
GlassEffectContainer {
List(selection: $selection) {
Section("Categories") {
ForEach(categories) { category in
Label(category.name, systemImage: category.icon)
.tag(category)
.listRowBackground(
selection == category ?
RoundedRectangle(cornerRadius: 8)
.glassEffect(.regular.tint(category.color.opacity(0.3)))
.glassEffectID("category", in: categorySelection)
: nil
)
}
}
Section("Smart Lists") {
Label("Favorites", systemImage: "star")
Label("Recent", systemImage: "clock")
}
}
}
}
}
Pattern 3: Floating Editor Panel
For editing content in an overlay. Pairs with appkit-bridge NSPanel.
struct FloatingEditorView: View {
@Bindable var prompt: Prompt
@State private var isPreviewExpanded = false
@Namespace private var editor
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Title field
TextField("Prompt title", text: $prompt.title)
.textFieldStyle(.plain)
.font(.title3)
.padding(12)
.glassEffect(.clear, in: .rect(cornerRadius: 10))
// Content editor
TextEditor(text: $prompt.content)
.font(.body)
.scrollContentBackground(.hidden)
.padding(12)
.frame(minHeight: 200)
.glassEffect(.clear, in: .rect(cornerRadius: 10))
// Expandable preview
Button {
withAnimation(.spring(response: 0.3)) {
isPreviewExpanded.toggle()
}
} label: {
HStack {
Image(systemName: "eye")
Text("Preview")
Spacer()
Image(systemName: "chevron.right")
.rotationEffect(.degrees(isPreviewExpanded ? 90 : 0))
}
}
.buttonStyle(.glass)
if isPreviewExpanded {
Text(prompt.content)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.glassEffect(.regular.tint(.green.opacity(0.2)), in: .rect(cornerRadius: 10))
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding()
}
.frame(width: 500, height: isPreviewExpanded ? 600 : 450)
}
}
Pattern 4: Compact Grid Palette
Grid selection with hover morphing. Good for quick access without search.
struct PromptPalette: View {
let prompts: [Prompt]
@State private var hoveredPrompt: Prompt?
@Namespace private var hover
var onSelect: (Prompt) -> Void
var body: some View {
GlassEffectContainer(spacing: 8) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))], spacing: 8) {
ForEach(prompts) { prompt in
VStack(spacing: 8) {
Image(systemName: prompt.icon)
.font(.title)
.foregroundStyle(prompt.categoryColor)
Text(prompt.title)
.font(.caption).fontWeight(.medium)
.lineLimit(2).multilineTextAlignment(.center)
}
.frame(width: 100, height: 80).padding(8)
.glassEffect(.regular, in: .rect(cornerRadius: 12))
.scaleEffect(hoveredPrompt == prompt ? 1.05 : 1.0)
.onHover { isHovered in
withAnimation(.easeOut(duration: 0.15)) {
hoveredPrompt = isHovered ? prompt : nil
}
}
.onTapGesture { onSelect(prompt) }
.background {
if hoveredPrompt == prompt {
RoundedRectangle(cornerRadius: 12)
.glassEffect(.regular.tint(.accentColor.opacity(0.3)))
.glassEffectID("hover", in: hover)
}
}
}
}
.padding()
}
.glassEffect(.clear, in: .rect(cornerRadius: 16))
}
}
Pattern 5: Settings Form with Glass Sections
struct SettingsView: View {
@AppStorage("hotkeyModifiers") var hotkeyModifiers: Int = 0
@AppStorage("hotkeyKey") var hotkeyKey: Int = 49
@AppStorage("launchAtLogin") var launchAtLogin = false
@AppStorage("showInDock") var showInDock = false
var body: some View {
Form {
Section {
HotkeyRecorder(modifiers: $hotkeyModifiers, key: $hotkeyKey)
} header: {
Label("Global Hotkey", systemImage: "keyboard")
}
.listRowBackground(
RoundedRectangle(cornerRadius: 10)
.glassEffect(.clear).padding(.vertical, 2)
)
Section {
Toggle("Launch at Login", isOn: $launchAtLogin)
Toggle("Show in Dock", isOn: $showInDock)
} header: {
Label("Behavior", systemImage: "gearshape")
}
.listRowBackground(
RoundedRectangle(cornerRadius: 10)
.glassEffect(.clear).padding(.vertical, 2)
)
}
.formStyle(.grouped)
.frame(width: 450, height: 350)
}
}
Pattern 6: Toast Notification
Ephemeral feedback overlay with auto-dismiss.
struct ToastView: View {
let message: String
let icon: String
var body: some View {
HStack(spacing: 10) {
Image(systemName: icon).font(.title3)
Text(message).fontWeight(.medium)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.glassEffect(.regular.tint(.green.opacity(0.3)), in: .capsule)
}
}
// Toast hosting in parent view
struct ContentView: View {
@State private var showToast = false
@State private var toastMessage = ""
var body: some View {
ZStack(alignment: .bottom) {
MainContent()
if showToast {
ToastView(message: toastMessage, icon: "checkmark.circle")
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 40)
}
}
.animation(.spring(response: 0.3), value: showToast)
}
func showToast(_ message: String) {
toastMessage = message
showToast = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { showToast = false }
}
}
Variable Placeholder System
Detection & Expansion
extension Prompt {
/// Extract {{variable}} names from content
var detectedVariables: [String] {
let pattern = #"\{\{(\w+)\}\}"#
let regex = try? NSRegularExpression(pattern: pattern)
let range = NSRange(content.startIndex..., in: content)
let matches = regex?.matches(in: content, range: range) ?? []
return matches.compactMap { match in
Range(match.range(at: 1), in: content).map { String(content[$0]) }
}
}
/// Fill variables and return expanded content
func filled(with values: [String: String]) -> String {
var result = content
for (name, value) in values {
result = result.replacingOccurrences(of: "{{\(name)}}", with: value)
}
return result
}
}
Variable Input UI
struct QuickVariablePopover: View {
let variables: [String]
@State private var values: [String: String] = [:]
@FocusState private var focusedField: String?
var onComplete: ([String: String]) -> Void
var body: some View {
VStack(spacing: 12) {
ForEach(Array(variables.enumerated()), id: \.element) { index, variable in
HStack {
Text(variable.capitalized)
.frame(width: 100, alignment: .trailing)
.foregroundStyle(.secondary)
TextField("", text: binding(for: variable))
.textFieldStyle(.plain).padding(8)
.glassEffect(.clear, in: .rect(cornerRadius: 8))
.focused($focusedField, equals: variable)
.onSubmit {
if index < variables.count - 1 {
focusedField = variables[index + 1]
} else { onComplete(values) }
}
}
}
HStack {
Spacer()
Button("Fill & Copy") { onComplete(values) }
.buttonStyle(.glassProminent)
.keyboardShortcut(.return, modifiers: .command)
}
}
.padding().frame(width: 350)
.onAppear { focusedField = variables.first }
}
func binding(for variable: String) -> Binding<String> {
Binding(get: { values[variable] ?? "" }, set: { values[variable] = $0 })
}
}
Syntax Highlighting for Variables
struct HighlightedPromptView: View {
let content: String
var body: some View {
Text(attributedContent).textSelection(.enabled)
}
var attributedContent: AttributedString {
var attributed = AttributedString(content)
let pattern = #"\{\{(\w+)\}\}"#
if let regex = try? NSRegularExpression(pattern: pattern) {
let range = NSRange(content.startIndex..., in: content)
for match in regex.matches(in: content, range: range).reversed() {
if let swiftRange = Range(match.range, in: content),
let attrRange = Range(swiftRange, in: attributed) {
attributed[attrRange].foregroundColor = .accentColor
attributed[attrRange].backgroundColor = .accentColor.opacity(0.1)
attributed[attrRange].font = .body.monospaced()
}
}
}
return attributed
}
}
Rich Multi-Format Copy
Copy as plain text + HTML + RTF so paste works well in any app.
#if os(macOS)
func copyRich(_ content: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
// Plain text
pasteboard.setString(content, forType: .string)
// HTML (for rich text apps like Mail, Pages)
let html = convertToHTML(content)
if let htmlData = html.data(using: .utf8) {
pasteboard.setData(htmlData, forType: .html)
}
// RTF
let attributed = NSAttributedString(
string: content,
attributes: [.font: NSFont.systemFont(ofSize: 14), .foregroundColor: NSColor.textColor]
)
if let rtfData = try? attributed.data(
from: NSRange(location: 0, length: attributed.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]
) {
pasteboard.setData(rtfData, forType: .rtf)
}
}
#endif
Anti-Patterns
â Glass Overload
// Too much glass â confusing and poor performance
VStack {
header.glassEffect()
content.glassEffect() // â Content shouldn't be glass
footer.glassEffect()
}
.glassEffect() // â Nested glass
â Targeted Glass
VStack {
header.glassEffect() // â
Navigation/chrome only
content // â
Content is clear
actionBar.glassEffect() // â
Floating actions
}
â Glass on Dark Backgrounds
ZStack {
Color.black
Text("Hard to see").glassEffect() // â Glass needs varied background
}
â Rich Backgrounds
ZStack {
Image("wallpaper").resizable()
Text("Easy to read").glassEffect() // â
Glass shines on varied imagery
}