feat(keyboard): improve typing, voice flow, and polish reliability
Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
This commit is contained in:
@@ -1,293 +0,0 @@
|
||||
// KeyboardOnboardingOverlay.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// v0.3.0 in-keyboard onboarding. Replaces the previous "jump out to
|
||||
// the host app" flow with a five-step overlay that lives on top of
|
||||
// the normal keyboard UI.
|
||||
//
|
||||
// Why in-keyboard instead of jumping to the host app?
|
||||
//
|
||||
// - iOS keyboard extensions **cannot programmatically switch back
|
||||
// to the previous app** after a host-app jump. The user has to
|
||||
// re-find their app, re-tap a text field, and re-select OSGKeyboard
|
||||
// from the globe menu. That's a 5+ tap friction.
|
||||
//
|
||||
// - Steps 1, 2, 4 (welcome, mic permission, speech permission,
|
||||
// API key) need nothing the host app owns. They can all live in
|
||||
// the keyboard.
|
||||
//
|
||||
// - The only step that *must* leave the keyboard is step 3
|
||||
// ("Enable Keyboard") — iOS requires the user to flip a toggle
|
||||
// in `Settings.app`, which is reachable from the extension via
|
||||
// `UIApplication.openSettingsURLString`. After the user comes
|
||||
// back, `viewWillAppear` reads `KeyboardSetupBridge.isReadyForOnboardingSkip`
|
||||
// and the overlay auto-advances past step 3.
|
||||
//
|
||||
// The overlay mounts only when `state.hasCompletedOnboarding == false`.
|
||||
// All inputs route through `KeyboardState` action hooks, so the
|
||||
// controller can mirror them into the App Group without the view
|
||||
// having to know about persistence.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct KeyboardOnboardingOverlay: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var state: KeyboardViewController.State
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Dim the underlying keyboard so the overlay reads as a
|
||||
// distinct surface. We can't completely hide it without
|
||||
// losing keyboard-system visibility, so a 60% black wash
|
||||
// is the sweet spot between focus and consistency.
|
||||
palette.background.opacity(0.96).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
|
||||
Spacer(minLength: Spacing.sm)
|
||||
|
||||
Group {
|
||||
switch currentStep {
|
||||
case .welcome: welcomeStep
|
||||
case .microphone: microphoneStep
|
||||
case .speech: speechStep
|
||||
case .keyboard: keyboardStep
|
||||
case .api: apiStep
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .trailing)))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Spacer(minLength: Spacing.sm)
|
||||
|
||||
footer
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Steps
|
||||
|
||||
private enum Step: Int, CaseIterable {
|
||||
case welcome = 0, microphone, speech, keyboard, api
|
||||
|
||||
static let count = 5
|
||||
}
|
||||
|
||||
private var currentStep: Step {
|
||||
Step(rawValue: state.onboardingPage) ?? .welcome
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<Step.count, id: \.self) { idx in
|
||||
Capsule()
|
||||
.fill(idx <= currentStep.rawValue
|
||||
? palette.accent
|
||||
: palette.divider)
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.xs)
|
||||
|
||||
Text(ExtL10n.string("keyboard.onboarding.title"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step content
|
||||
|
||||
private var welcomeStep: some View {
|
||||
stepBody(
|
||||
iconSystemName: "waveform.badge.mic",
|
||||
title: "keyboard.onboarding.welcome.title",
|
||||
body: "keyboard.onboarding.welcome.body"
|
||||
)
|
||||
}
|
||||
|
||||
private var microphoneStep: some View {
|
||||
stepBody(
|
||||
iconSystemName: "mic.fill",
|
||||
title: "keyboard.onboarding.mic.title",
|
||||
body: "keyboard.onboarding.mic.body"
|
||||
)
|
||||
}
|
||||
|
||||
private var speechStep: some View {
|
||||
stepBody(
|
||||
iconSystemName: "ear",
|
||||
title: "keyboard.onboarding.speech.title",
|
||||
body: "keyboard.onboarding.speech.body"
|
||||
)
|
||||
}
|
||||
|
||||
private var keyboardStep: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Image(systemName: "keyboard")
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(palette.accent)
|
||||
Text(ExtL10n.string("keyboard.onboarding.keyboard.title"))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(ExtL10n.string("keyboard.onboarding.keyboard.body"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button {
|
||||
state.openSystemSettings()
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
Text(ExtL10n.string("keyboard.onboarding.keyboard.openSettings"))
|
||||
}
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.xs + 2)
|
||||
.background(palette.accent, in: Capsule())
|
||||
}
|
||||
.accessibilityLabel(ExtL10n.string("keyboard.onboarding.keyboard.openSettings"))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var apiStep: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Image(systemName: "key.fill")
|
||||
.font(.system(size: 32, weight: .light))
|
||||
.foregroundStyle(palette.accent)
|
||||
Text(ExtL10n.string("keyboard.onboarding.api.title"))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(ExtL10n.string("keyboard.onboarding.api.body"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(ExtL10n.string("keyboard.onboarding.api.skipHint"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func stepBody(
|
||||
iconSystemName: String,
|
||||
title: String,
|
||||
body: String
|
||||
) -> some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Image(systemName: iconSystemName)
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(palette.accent)
|
||||
Text(ExtL10n.string(title))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(ExtL10n.string(body))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
private var footer: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if currentStep != .welcome {
|
||||
Button(ExtL10n.string("keyboard.onboarding.back")) {
|
||||
state.onboardingPage = max(0, currentStep.rawValue - 1)
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(minHeight: 36)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
primaryButton
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var primaryButton: some View {
|
||||
switch currentStep {
|
||||
case .welcome:
|
||||
Button(ExtL10n.string("keyboard.onboarding.getStarted")) {
|
||||
state.onboardingPage = 1
|
||||
}
|
||||
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
|
||||
|
||||
case .microphone:
|
||||
Button(ExtL10n.string("keyboard.onboarding.mic.grant")) {
|
||||
state.requestMicPermission()
|
||||
// Optimistically advance — if permission is denied the
|
||||
// status text on the next viewWillAppear will reflect it.
|
||||
state.onboardingPage = 2
|
||||
}
|
||||
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
|
||||
|
||||
case .speech:
|
||||
Button(ExtL10n.string("keyboard.onboarding.speech.grant")) {
|
||||
state.requestSpeechPermission()
|
||||
state.onboardingPage = 3
|
||||
}
|
||||
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
|
||||
|
||||
case .keyboard:
|
||||
// Step 3 is auto-advanced by viewWillAppear once the user
|
||||
// has enabled the keyboard in Settings.app. We don't show
|
||||
// a "Continue" button here — that would re-trigger the
|
||||
// confusion we're solving.
|
||||
Button(ExtL10n.string("keyboard.onboarding.keyboard.openSettings")) {
|
||||
state.openSystemSettings()
|
||||
}
|
||||
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
|
||||
|
||||
case .api:
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Button(ExtL10n.string("keyboard.onboarding.api.skip")) {
|
||||
state.completeOnboarding()
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 36)
|
||||
|
||||
Button(ExtL10n.string("keyboard.onboarding.api.openHostApp")) {
|
||||
state.openSettings()
|
||||
}
|
||||
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OverlayPrimaryButtonStyle: ButtonStyle {
|
||||
let palette: ThemePalette
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.xs + 2)
|
||||
.background(palette.accent.opacity(configuration.isPressed ? 0.7 : 1.0),
|
||||
in: Capsule())
|
||||
.frame(minHeight: 36)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,7 @@ private enum KeyboardLayoutMetrics {
|
||||
static let micSize: CGFloat = 121
|
||||
static let micToButtonGap: CGFloat = 8
|
||||
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
|
||||
static let bottomActionFixedWidth: CGFloat = KeyboardChromeLayout.sideActionKeyWidth
|
||||
static let bottomActionSpacing: CGFloat = Spacing.xs
|
||||
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
|
||||
/// Gap between the top control row and the transcript / hint line.
|
||||
/// Four points keeps the "点按说话" line visually attached to the controls.
|
||||
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
|
||||
@@ -34,7 +33,7 @@ private enum KeyboardLayoutMetrics {
|
||||
/// park delete/return at the far screen edges and turn each cursor-drag
|
||||
/// pad into a ~450 pt runway — capping keeps the reach ergonomics of the
|
||||
/// phone layout. iPhone widths are all below this, so it is a no-op there.
|
||||
static let contentMaxWidth: CGFloat = 700
|
||||
static let contentMaxWidth: CGFloat = KeyboardChromeLayout.contentMaxWidth
|
||||
|
||||
// MARK: - Content-driven keyboard height (single source of truth)
|
||||
static let outerPaddingTop: CGFloat = 4
|
||||
@@ -45,6 +44,8 @@ private enum KeyboardLayoutMetrics {
|
||||
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
|
||||
/// Moves the action cluster down so its keys share the typing row's baseline.
|
||||
static let actionClusterTopGap: CGFloat = Spacing.xl
|
||||
/// Centres the mic between the transcript hint and bottom action row.
|
||||
static let micUpwardAdjustment: CGFloat = (actionClusterTopGap - micToButtonGap) / 2
|
||||
/// The shared 4 pt outer padding is the complete bottom inset.
|
||||
static let actionClusterBottomGap: CGFloat = 0
|
||||
|
||||
@@ -65,7 +66,7 @@ public struct KeyboardRootView: View {
|
||||
|
||||
public init(
|
||||
state: KeyboardViewController.State,
|
||||
typing: TypingSessionController = TypingSessionController(),
|
||||
typing: TypingSessionController,
|
||||
onInsert: @escaping (String) -> Void = { _ in }
|
||||
) {
|
||||
self.state = state
|
||||
@@ -85,6 +86,7 @@ public struct KeyboardRootView: View {
|
||||
static let micTopOffset: CGFloat = KeyboardLayoutMetrics.outerPaddingTop
|
||||
+ KeyboardLayoutMetrics.headerBandHeight
|
||||
+ KeyboardLayoutMetrics.actionClusterTopGap
|
||||
- KeyboardLayoutMetrics.micUpwardAdjustment
|
||||
/// Horizontal inset the side pads should respect.
|
||||
static let sideInset: CGFloat = KeyboardLayoutMetrics.sideActionHorizontalInset
|
||||
|
||||
@@ -115,20 +117,7 @@ public struct KeyboardRootView: View {
|
||||
.frame(height: Self.totalHeight)
|
||||
// Feed the resolved palette to all nested chips/buttons.
|
||||
.environment(\.themePalette, palette)
|
||||
|
||||
// v0.3.0: in-keyboard first-launch onboarding. Mounted as
|
||||
// an overlay so the normal keyboard chrome stays
|
||||
// responsive underneath (mic button still works, chip
|
||||
// taps register). Only rendered until
|
||||
// `state.hasCompletedOnboarding` flips to true; from
|
||||
// then on the overlay is unmounted and never re-rendered.
|
||||
if !state.hasCompletedOnboarding {
|
||||
KeyboardOnboardingOverlay(state: state)
|
||||
.environment(\.themePalette, palette)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: state.hasCompletedOnboarding)
|
||||
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
|
||||
}
|
||||
|
||||
@@ -196,23 +185,37 @@ public struct KeyboardRootView: View {
|
||||
onToggle: state.tapMic
|
||||
)
|
||||
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
|
||||
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
|
||||
.opacity(dragging ? 0 : 1)
|
||||
|
||||
cursorDragPad(enabled: cursorPadsEnabled)
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.micSize)
|
||||
|
||||
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
|
||||
if swapKeys {
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
} else {
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
GeometryReader { proxy in
|
||||
let widths = KeyboardChromeLayout.actionKeyWidths(
|
||||
availableWidth: proxy.size.width
|
||||
)
|
||||
|
||||
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
|
||||
if swapKeys {
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
.frame(width: widths.center)
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side)
|
||||
} else {
|
||||
bottomDeleteButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side)
|
||||
bottomReturnButton(disabled: editingBlocked)
|
||||
.frame(width: widths.center)
|
||||
bottomSpaceButton(disabled: editingBlocked)
|
||||
.frame(width: widths.side)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
.opacity(dragging ? 0 : 1)
|
||||
}
|
||||
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
|
||||
@@ -234,20 +237,14 @@ public struct KeyboardRootView: View {
|
||||
RepeatingDeleteButton(disabled: disabled) {
|
||||
state.deleteBackward()
|
||||
}
|
||||
.frame(
|
||||
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
|
||||
height: KeyboardLayoutMetrics.bottomActionRowHeight
|
||||
)
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
}
|
||||
|
||||
private func bottomSpaceButton(disabled: Bool) -> some View {
|
||||
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
|
||||
state.insertSpace()
|
||||
}
|
||||
.frame(
|
||||
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
|
||||
height: KeyboardLayoutMetrics.bottomActionRowHeight
|
||||
)
|
||||
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
|
||||
}
|
||||
|
||||
private func bottomReturnButton(disabled: Bool) -> some View {
|
||||
@@ -299,19 +296,28 @@ extension KeyboardRootView {
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Keyboard · Idle") {
|
||||
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
|
||||
KeyboardRootView(
|
||||
state: KeyboardViewController.State.previewIdle,
|
||||
typing: TypingSessionController()
|
||||
)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Keyboard · Recording") {
|
||||
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
|
||||
KeyboardRootView(
|
||||
state: KeyboardViewController.State.previewRecording,
|
||||
typing: TypingSessionController()
|
||||
)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Keyboard · Processing") {
|
||||
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
|
||||
KeyboardRootView(
|
||||
state: KeyboardViewController.State.previewProcessing,
|
||||
typing: TypingSessionController()
|
||||
)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
@@ -417,6 +423,8 @@ private struct TranscriptLine: View {
|
||||
ExtL10n.text("keyboard.error.fullAccessRequired")
|
||||
case .unavailable(.appGroupUnavailable):
|
||||
ExtL10n.text("keyboard.error.appGroupCommunication")
|
||||
case .unavailable(.onboardingIncomplete):
|
||||
ExtL10n.text("keyboard.hint.finishSetupInApp")
|
||||
case .recording, .processing:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ struct KeyboardBrandLogo: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.buttonStyle(BrandLogoPressStyle())
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.onboarding.api.openHostApp"))
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.openSettingsA11y"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,8 @@ struct RepeatingPressButton<Label: View>: View {
|
||||
var disabled: Bool = false
|
||||
/// Plays the system delete click on each fire (matches stock keyboard).
|
||||
var playsDeleteSound: Bool = true
|
||||
/// Typing-grid haptic strength; `.off` skips haptics (voice toolbar default).
|
||||
var hapticIntensity: KeyboardHapticIntensity = .off
|
||||
let action: () -> Void
|
||||
@ViewBuilder let label: (_ isPressed: Bool) -> Label
|
||||
|
||||
@@ -130,6 +132,7 @@ struct RepeatingPressButton<Label: View>: View {
|
||||
if playsDeleteSound {
|
||||
KeyboardSoundFeedback.deleteClick()
|
||||
}
|
||||
KeyboardHapticFeedback.play(role: .delete, intensity: hapticIntensity)
|
||||
action()
|
||||
}
|
||||
|
||||
@@ -180,6 +183,39 @@ struct RepeatingDeleteButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Press-down typing key
|
||||
|
||||
/// Fires on touch-down (not release) so click sound / haptic match the stock
|
||||
/// keyboard and the voice toolbar’s RectangularToolbarButton.
|
||||
struct PressDownKeyButton<Label: View>: View {
|
||||
var disabled: Bool = false
|
||||
let action: () -> Void
|
||||
@ViewBuilder let label: (_ isPressed: Bool) -> Label
|
||||
|
||||
@State private var isPressing = false
|
||||
|
||||
var body: some View {
|
||||
label(isPressing)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(pressGesture)
|
||||
.opacity(disabled ? 0.38 : 1)
|
||||
.allowsHitTesting(!disabled)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
|
||||
private var pressGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
guard !disabled, !isPressing else { return }
|
||||
isPressing = true
|
||||
action()
|
||||
}
|
||||
.onEnded { _ in
|
||||
isPressing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rectangular toolbar button
|
||||
|
||||
struct RectangularToolbarButton: View {
|
||||
|
||||
Reference in New Issue
Block a user