Remove bouncy motion from mobile apps
This commit is contained in:
1 parent
49de42a430
commit
891909bfb4
8 files changed
+44
-427
No files matched your search
@@ -1,262 +1,19 @@
|
||||
import SwiftUI
|
||||
import Pow
|
||||
|
||||
/// Shared motion language for EdgeEver iOS — tuned to feel like Android RN.
|
||||
///
|
||||
/// Android is intentionally **restrained**:
|
||||
/// - Memo cards: Reanimated `scale 0.985` (100ms in / 160ms out), no list enter cascade
|
||||
/// - Create / filter chips: plain presses (no spring bounce)
|
||||
/// - No Fabric layout-enter effects on list cards
|
||||
///
|
||||
/// Earlier iOS motion overshot (0.97 scale, heavy brightness, springy list reshuffles,
|
||||
/// Pow angled card-appear) and felt busier / less comfortable than Android. Prefer
|
||||
/// quiet timing curves and high-damping springs over theatrical micro-effects.
|
||||
/// Short, non-spring transitions for state changes that need visual continuity.
|
||||
enum Motion {
|
||||
// MARK: Timing (match Android Reanimated `withTiming`)
|
||||
|
||||
/// Card press-in: `withTiming(0.985, { duration: 100 })`
|
||||
static let cardPressIn = Animation.easeOut(duration: 0.10)
|
||||
/// Card press-out: `withTiming(1, { duration: 160 })`
|
||||
static let cardPressOut = Animation.easeOut(duration: 0.16)
|
||||
|
||||
/// Short control press (create / chips) — ease, not bouncy spring
|
||||
static let controlPress = Animation.easeOut(duration: 0.12)
|
||||
|
||||
/// Filter / chip selection settle (high damping ≈ Material, not iOS bounce)
|
||||
static let chip = Animation.spring(response: 0.34, dampingFraction: 0.92)
|
||||
|
||||
/// Bottom-nav create (+) — quiet scale, no rubber-band
|
||||
static let createButton = Animation.easeOut(duration: 0.14)
|
||||
|
||||
/// Search bar expand / constraint banner
|
||||
static let search = Animation.spring(response: 0.38, dampingFraction: 0.92)
|
||||
|
||||
/// List content changes (filter/search) — calm cross-fade, not floaty
|
||||
static let chip = Animation.easeOut(duration: 0.18)
|
||||
static let search = Animation.easeInOut(duration: 0.22)
|
||||
static let listContent = Animation.easeInOut(duration: 0.22)
|
||||
|
||||
/// Settings / sheet presentation polish
|
||||
static let sheet = Animation.spring(response: 0.42, dampingFraction: 0.92)
|
||||
|
||||
/// Stagger delay step between first-paint list cards (entrance cascade).
|
||||
static let listEntranceStagger: Double = 0.045
|
||||
|
||||
/// Whole-list container spring when data first lands.
|
||||
static let listEntrance = Animation.spring(response: 0.52, dampingFraction: 0.72)
|
||||
|
||||
// MARK: Transitions (Pow Moving Parts)
|
||||
|
||||
/// Filter/search reshuffle: quiet fade.
|
||||
static var cardAppear: AnyTransition {
|
||||
.opacity
|
||||
}
|
||||
|
||||
/// First-paint list cards: elastic “boing” drop from top (clear rebound, not a fade).
|
||||
static var listCardEntrance: AnyTransition {
|
||||
.asymmetric(
|
||||
insertion: .movingParts.boing(edge: .top).combined(with: .opacity),
|
||||
removal: .opacity
|
||||
)
|
||||
}
|
||||
|
||||
static var panelAppear: AnyTransition {
|
||||
.opacity.combined(with: .move(edge: .bottom))
|
||||
}
|
||||
|
||||
static var softFade: AnyTransition {
|
||||
.opacity
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - List entrance + return settle
|
||||
|
||||
/// Whole list: soft drop-in when notes first appear this session.
|
||||
struct NotesListEntranceModifier: ViewModifier {
|
||||
var entrancePulse: Int
|
||||
var settled: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.opacity(settled ? 1 : 0.001)
|
||||
.offset(y: settled ? 0 : 20)
|
||||
.animation(Motion.listEntrance, value: settled)
|
||||
}
|
||||
}
|
||||
|
||||
/// Return-from-create/edit settle on one card.
|
||||
///
|
||||
/// Designed to run **in the same beat as fullScreenCover dismiss** (~0.35s):
|
||||
/// 1. Instantly snap to a slightly compressed pose (no animation) — often under the cover
|
||||
/// 2. Immediately spring to rest — by the time the cover has cleared, motion is already continuous
|
||||
///
|
||||
/// Avoids: wait for dismiss → pause ~0.5s → sudden Pow jump (felt discontinuous).
|
||||
struct MemoReturnBounceModifier: ViewModifier {
|
||||
var pulse: Int
|
||||
|
||||
@State private var scale: CGFloat = 1
|
||||
@State private var offsetY: CGFloat = 0
|
||||
@State private var lastPlayed = 0
|
||||
|
||||
/// Matches UIKit sheet dismiss (~0.35–0.4s) so settle and cover share one motion phrase.
|
||||
private static let settle = Animation.spring(response: 0.42, dampingFraction: 0.78)
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.offset(y: offsetY)
|
||||
.scaleEffect(scale, anchor: .center)
|
||||
.onChange(of: pulse) { _, newValue in
|
||||
playIfNeeded(newValue)
|
||||
}
|
||||
.onAppear {
|
||||
playIfNeeded(pulse)
|
||||
}
|
||||
}
|
||||
|
||||
private func playIfNeeded(_ value: Int) {
|
||||
guard value > 0, value != lastPlayed else { return }
|
||||
lastPlayed = value
|
||||
// Pose A: already mid-settle (no delay, no Transaction lag).
|
||||
var snap = Transaction()
|
||||
snap.disablesAnimations = true
|
||||
withTransaction(snap) {
|
||||
scale = 0.96
|
||||
offsetY = 14
|
||||
}
|
||||
// Pose B: spring home immediately — continuous with the covering dismiss.
|
||||
withAnimation(Self.settle) {
|
||||
scale = 1
|
||||
offsetY = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func edgeEverNotesListEntrance(settled: Bool, entrancePulse: Int) -> some View {
|
||||
modifier(NotesListEntranceModifier(entrancePulse: entrancePulse, settled: settled))
|
||||
}
|
||||
|
||||
func edgeEverMemoReturnBounce(pulse: Int) -> some View {
|
||||
modifier(MemoReturnBounceModifier(pulse: pulse))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reusable modifiers
|
||||
|
||||
/// Android memo-card "弹跳压缩": scale down on finger-down, spring back on release.
|
||||
///
|
||||
/// Android RN: Reanimated `withTiming(0.985 @ 100ms)` / `withTiming(1 @ 160ms)`.
|
||||
/// 0.985 is nearly invisible on iOS compositing, so we use a slightly stronger
|
||||
/// compress (still quiet) and a soft spring on release so the bounce-back reads.
|
||||
enum MemoCardPress {
|
||||
/// Compress target — stronger than raw Android 0.985 so it is actually visible.
|
||||
static let pressedScale: CGFloat = 0.97
|
||||
/// Finger-down: match Android 100ms ease-out compress.
|
||||
static let pressIn = Animation.easeOut(duration: 0.10)
|
||||
/// Finger-up: soft bounce settle (reads as 弹跳, not rubber-band).
|
||||
static let pressOut = Animation.spring(response: 0.30, dampingFraction: 0.62)
|
||||
}
|
||||
|
||||
/// Primary path for list cards — compress on press, soft spring bounce on release.
|
||||
struct MemoCardPressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? MemoCardPress.pressedScale : 1, anchor: .center)
|
||||
.animation(
|
||||
configuration.isPressed ? MemoCardPress.pressIn : MemoCardPress.pressOut,
|
||||
value: configuration.isPressed
|
||||
)
|
||||
// Keep press animation independent of list LazyVStack springs.
|
||||
.geometryGroup()
|
||||
.onChange(of: configuration.isPressed) { _, pressed in
|
||||
if pressed {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred(intensity: 0.4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom-nav create (+) compress + soft bounce back.
|
||||
struct CreateButtonPressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.90 : 1, anchor: .center)
|
||||
.animation(
|
||||
configuration.isPressed
|
||||
? Animation.easeOut(duration: 0.10)
|
||||
: Animation.spring(response: 0.32, dampingFraction: 0.55),
|
||||
value: configuration.isPressed
|
||||
)
|
||||
.geometryGroup()
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular filter chip press compress.
|
||||
struct FilterChipButtonStyle: ButtonStyle {
|
||||
var active: Bool
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.92 : 1, anchor: .center)
|
||||
.animation(
|
||||
configuration.isPressed
|
||||
? Animation.easeOut(duration: 0.10)
|
||||
: Animation.spring(response: 0.30, dampingFraction: 0.65),
|
||||
value: configuration.isPressed
|
||||
)
|
||||
.animation(Motion.chip, value: active)
|
||||
.geometryGroup()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gesture-driven compress for memo cards.
|
||||
///
|
||||
/// Why not only `ButtonStyle.isPressed`?
|
||||
/// `contextMenu` + long-press selection steal the button highlight, so isPressed
|
||||
/// often never becomes true. We track finger-down ourselves (Android onPressIn/Out).
|
||||
///
|
||||
/// Why `@State` not `@GestureState`?
|
||||
/// `@GestureState` snaps back without driving a spring animation — release looked dead.
|
||||
struct MemoCardPressHighlight: ViewModifier {
|
||||
@State private var isPressed = false
|
||||
|
||||
private static let scrollCancelDistance: CGFloat = 12
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.scaleEffect(isPressed ? MemoCardPress.pressedScale : 1, anchor: .center)
|
||||
.animation(
|
||||
isPressed ? MemoCardPress.pressIn : MemoCardPress.pressOut,
|
||||
value: isPressed
|
||||
)
|
||||
.geometryGroup()
|
||||
.contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .local)
|
||||
.onChanged { value in
|
||||
let moved = hypot(value.translation.width, value.translation.height)
|
||||
let next = moved < Self.scrollCancelDistance
|
||||
guard isPressed != next else { return }
|
||||
isPressed = next
|
||||
if next {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred(intensity: 0.45)
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
guard isPressed else { return }
|
||||
isPressed = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Android-style memo-card compress + bounce-back (works with contextMenu).
|
||||
func edgeEverMemoCardPress() -> some View {
|
||||
modifier(MemoCardPressHighlight())
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Haptic selection tick when `value` changes (Pow).
|
||||
/// Haptic selection tick when `value` changes.
|
||||
func edgeEverSelectionFeedback<V: Equatable>(_ value: V) -> some View {
|
||||
changeEffect(.feedbackHapticSelection, value: value)
|
||||
}
|
||||
@@ -266,21 +23,8 @@ extension View {
|
||||
changeEffect(.shine, value: trigger, isEnabled: trigger)
|
||||
}
|
||||
|
||||
/// Jump micro-bounce when value changes (e.g. create success / sync done).
|
||||
func edgeEverJump(on value: some Equatable, height: CGFloat = 4) -> some View {
|
||||
changeEffect(.jump(height: height), value: value)
|
||||
}
|
||||
|
||||
/// Shake on error flag rising.
|
||||
/// Draw attention to an error when it appears.
|
||||
func edgeEverErrorShake(on error: String?) -> some View {
|
||||
changeEffect(.shake, value: error ?? "", isEnabled: error != nil && !(error?.isEmpty ?? true))
|
||||
}
|
||||
|
||||
/// Soft ping on the create button (single, quieter than before).
|
||||
func edgeEverCreatePing(count: Int) -> some View {
|
||||
changeEffect(
|
||||
.ping(shape: Circle(), style: AppTheme.accentBright.opacity(0.28), count: 1),
|
||||
value: count
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ struct LoginView: View {
|
||||
.background(canSubmit ? AppTheme.title : AppTheme.disabledFill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
.buttonStyle(CreateButtonPressStyle())
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canSubmit)
|
||||
.changeEffect(.shake, value: loginShake)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ struct MemoEditView: View {
|
||||
/// When set (edit-from-detail), close by popping to the list under the cover first —
|
||||
/// never `dismiss()` onto a still-pushed detail page.
|
||||
var onLeaveToList: (() -> Void)? = nil
|
||||
/// Create path: called with the committed memo id so the list can bounce that card.
|
||||
/// Create path: called after commit so the workspace can refresh its list.
|
||||
var onCreateFinished: ((String) -> Void)? = nil
|
||||
|
||||
@State private var viewModel = MemoEditViewModel()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import SwiftUI
|
||||
import Pow
|
||||
|
||||
struct NotesListView: View {
|
||||
@Environment(AppEnvironment.self) private var env
|
||||
@@ -9,13 +8,6 @@ struct NotesListView: View {
|
||||
var onCreateFromTemplate: (() -> Void)? = nil
|
||||
var onCreateNotebook: (() -> Void)? = nil
|
||||
|
||||
/// Whole-list settle + Pow jump once when data first becomes available this session.
|
||||
@State private var listEntranceSettled = false
|
||||
@State private var listEntrancePulse = 0
|
||||
@State private var didScheduleListEntrance = false
|
||||
/// First-paint cascade: cards insert with Pow boing (cleared after entrance finishes).
|
||||
@State private var listEntranceCascade = false
|
||||
|
||||
/// First-login / first-mirror bootstrap (Android `initialSyncProgress`).
|
||||
private var hasBootstrapProgress: Bool {
|
||||
env.bootstrapProgress != nil
|
||||
@@ -80,86 +72,44 @@ struct NotesListView: View {
|
||||
)
|
||||
.transition(Motion.softFade)
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
// Android FlatList `list`: paddingTop 12, paddingHorizontal 12, paddingBottom 18
|
||||
LazyVStack(spacing: 0) {
|
||||
if hasBootstrapProgress {
|
||||
bootstrapProgressBanner
|
||||
// memoSyncBanner marginBottom: 10
|
||||
.padding(.bottom, 10)
|
||||
} else if env.lastSyncError != nil, !store.memos.isEmpty {
|
||||
syncPausedBanner
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
ScrollView {
|
||||
// Android FlatList `list`: paddingTop 12, paddingHorizontal 12, paddingBottom 18
|
||||
LazyVStack(spacing: 0) {
|
||||
if hasBootstrapProgress {
|
||||
bootstrapProgressBanner
|
||||
// memoSyncBanner marginBottom: 10
|
||||
.padding(.bottom, 10)
|
||||
} else if env.lastSyncError != nil, !store.memos.isEmpty {
|
||||
syncPausedBanner
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
ForEach(Array(store.memos.enumerated()), id: \.element.id) { index, memo in
|
||||
memoCard(for: memo)
|
||||
.padding(.bottom, env.preferences.listDensity.cardBottomMargin)
|
||||
.id(memo.id)
|
||||
// First open: elastic boing cascade. Later reshuffles: quiet opacity.
|
||||
.transition(listEntranceCascade ? Motion.listCardEntrance : Motion.cardAppear)
|
||||
.animation(
|
||||
listEntranceCascade
|
||||
? Motion.listEntrance.delay(Double(min(index, 12)) * Motion.listEntranceStagger)
|
||||
: Motion.listContent,
|
||||
value: listEntranceCascade
|
||||
)
|
||||
.onAppear {
|
||||
if memo.id == store.memos.last?.id {
|
||||
store.loadMore(env: env)
|
||||
}
|
||||
ForEach(store.memos, id: \.id) { memo in
|
||||
memoCard(for: memo)
|
||||
.padding(.bottom, env.preferences.listDensity.cardBottomMargin)
|
||||
.onAppear {
|
||||
if memo.id == store.memos.last?.id {
|
||||
store.loadMore(env: env)
|
||||
}
|
||||
}
|
||||
if store.isLoadingMore {
|
||||
ProgressView()
|
||||
.tint(AppTheme.title)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 18)
|
||||
.animation(Motion.listContent, value: store.memos.map(\.id))
|
||||
.animation(Motion.listContent, value: store.filter)
|
||||
.animation(Motion.listContent, value: store.selectedTag)
|
||||
.animation(Motion.listContent, value: store.searchText)
|
||||
.animation(Motion.search, value: hasBootstrapProgress)
|
||||
}
|
||||
.contentMargins(.bottom, 0, for: .scrollContent)
|
||||
.background(AppTheme.background)
|
||||
.edgeEverNotesListEntrance(settled: listEntranceSettled, entrancePulse: listEntrancePulse)
|
||||
.onChange(of: store.bounceMemoId) { _, memoId in
|
||||
guard let memoId else { return }
|
||||
// Scroll immediately (no delayed animation beat) so the settling card is on-screen.
|
||||
if store.memos.contains(where: { $0.id == memoId }) {
|
||||
proxy.scrollTo(memoId, anchor: .top)
|
||||
}
|
||||
// Clear bounce marker after settle completes (~0.42s spring).
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
if store.bounceMemoId == memoId {
|
||||
store.clearMemoBounce()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: store.bouncePulse) { _, _ in
|
||||
// If id remapped after create sync, ensure the new row is visible.
|
||||
if let memoId = store.bounceMemoId,
|
||||
store.memos.contains(where: { $0.id == memoId }) {
|
||||
proxy.scrollTo(memoId, anchor: .top)
|
||||
if store.isLoadingMore {
|
||||
ProgressView()
|
||||
.tint(AppTheme.title)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 18)
|
||||
.animation(Motion.search, value: hasBootstrapProgress)
|
||||
}
|
||||
.contentMargins(.bottom, 0, for: .scrollContent)
|
||||
.background(AppTheme.background)
|
||||
}
|
||||
}
|
||||
.animation(Motion.listContent, value: store.memos.isEmpty)
|
||||
.animation(Motion.search, value: hasBootstrapProgress)
|
||||
.onChange(of: store.memos.count) { _, count in
|
||||
scheduleListEntranceIfNeeded(hasMemos: count > 0)
|
||||
}
|
||||
.onAppear {
|
||||
scheduleListEntranceIfNeeded(hasMemos: !store.memos.isEmpty)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if let err = store.listError {
|
||||
Text(err)
|
||||
@@ -384,23 +334,6 @@ struct NotesListView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func scheduleListEntranceIfNeeded(hasMemos: Bool) {
|
||||
guard hasMemos, !didScheduleListEntrance else { return }
|
||||
didScheduleListEntrance = true
|
||||
listEntranceSettled = false
|
||||
listEntranceCascade = true
|
||||
// Next frame: settle in (stagger lives on per-card delay, not a late global jump).
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(Motion.listEntrance) {
|
||||
listEntranceSettled = true
|
||||
}
|
||||
listEntrancePulse &+= 1
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.85) {
|
||||
listEntranceCascade = false
|
||||
}
|
||||
}
|
||||
|
||||
/// With sub-notebooks hidden, an empty parent must not look like its notes are gone.
|
||||
private var hiddenDescendantMemoCount: Int {
|
||||
guard !env.preferences.showDescendantNotes,
|
||||
@@ -536,7 +469,6 @@ struct NotesListView: View {
|
||||
private func memoCard(for memo: MemoSummary) -> some View {
|
||||
let selected = store.selectedMemoIds.contains(memo.id)
|
||||
let density = env.preferences.listDensity
|
||||
let bouncePulse = store.bounceMemoId == memo.id ? store.bouncePulse : 0
|
||||
|
||||
Button {
|
||||
if store.selectionMode {
|
||||
@@ -564,9 +496,7 @@ struct NotesListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(MemoCardPressStyle())
|
||||
// Return-from-create/edit rebound on this card only.
|
||||
.edgeEverMemoReturnBounce(pulse: bouncePulse)
|
||||
.buttonStyle(.plain)
|
||||
.edgeEverSelectionFeedback(selected)
|
||||
.simultaneousGesture(
|
||||
LongPressGesture(minimumDuration: 0.52).onEnded { _ in
|
||||
|
||||
@@ -23,11 +23,6 @@ final class WorkspaceStore {
|
||||
var showActions = false
|
||||
var showTagFilterPicker = false
|
||||
|
||||
/// Memo id that should play a return-bounce when the list reappears after create/edit.
|
||||
var bounceMemoId: String?
|
||||
/// Bumped so the same memo can bounce again on a later return.
|
||||
var bouncePulse: Int = 0
|
||||
|
||||
private var searchTask: Task<Void, Never>?
|
||||
|
||||
var activeNotebook: Notebook? {
|
||||
@@ -155,17 +150,6 @@ final class WorkspaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request a rebound animation on a list card after create/edit returns to the list.
|
||||
func requestMemoBounce(memoId: String?) {
|
||||
guard let memoId, !memoId.isEmpty else { return }
|
||||
bounceMemoId = memoId
|
||||
bouncePulse &+= 1
|
||||
}
|
||||
|
||||
func clearMemoBounce() {
|
||||
bounceMemoId = nil
|
||||
}
|
||||
|
||||
func softDelete(env: AppEnvironment, memoId: String) async {
|
||||
guard let scope = env.session.dataScope else { return }
|
||||
do {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Pow
|
||||
|
||||
/// Wrapper so `fullScreenCover(item:)` can present edit for a memo id.
|
||||
struct EditingMemoRoute: Identifiable, Hashable {
|
||||
@@ -22,12 +21,8 @@ struct WorkspaceView: View {
|
||||
@State private var showMoveSheet = false
|
||||
@State private var showSelectionMore = false
|
||||
@State private var conflictItem: OutboxItem?
|
||||
@State private var createTapCount = 0
|
||||
@State private var syncPulse = 0
|
||||
/// Edit is presented from the workspace root — more reliable than cover on a pushed detail page.
|
||||
@State private var editingMemo: EditingMemoRoute?
|
||||
/// Create finished id, applied as list bounce after cover dismiss + reload.
|
||||
@State private var pendingCreateBounceId: String?
|
||||
@State private var incomingClipURL: URL?
|
||||
@State private var isImportingShare = false
|
||||
@State private var shareImportAlert: ShareImportAlert?
|
||||
@@ -100,12 +95,8 @@ struct WorkspaceView: View {
|
||||
seed: createSeed
|
||||
),
|
||||
initialSharedImages: createSharedImages,
|
||||
onCreateFinished: { memoId in
|
||||
// Prime list + bounce **before** dismiss so settle runs under/with the cover,
|
||||
// not half a second after the list is already static.
|
||||
onCreateFinished: { _ in
|
||||
store.reload(env: env)
|
||||
store.requestMemoBounce(memoId: memoId)
|
||||
pendingCreateBounceId = nil
|
||||
}
|
||||
)
|
||||
.onDisappear {
|
||||
@@ -135,11 +126,7 @@ struct WorkspaceView: View {
|
||||
onLeaveToList: {
|
||||
// Pop detail first (no animation) while cover still covers the stack,
|
||||
// then dismiss the cover so the user only ever sees the list.
|
||||
let bounceId = route.id
|
||||
// Reload + start settle **before** clearing the cover so the spring
|
||||
// is already in motion when the list is revealed (no post-dismiss pause).
|
||||
store.reload(env: env)
|
||||
store.requestMemoBounce(memoId: bounceId)
|
||||
var t = Transaction()
|
||||
t.disablesAnimations = true
|
||||
withTransaction(t) {
|
||||
@@ -204,7 +191,6 @@ struct WorkspaceView: View {
|
||||
// Refresh list when a background/bootstrap sync finishes.
|
||||
if wasSyncing && !isSyncing {
|
||||
store.reload(env: env)
|
||||
syncPulse += 1
|
||||
}
|
||||
}
|
||||
// Android invalidates the memo list on each bootstrap batch so the UI
|
||||
@@ -348,7 +334,6 @@ struct WorkspaceView: View {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(searchActive ? AppTheme.accent : AppTheme.secondary)
|
||||
.symbolEffect(.bounce, value: searchActive)
|
||||
TextField(env.preferences.t("搜索笔记", en: "Search notes", pl: "Szukaj notatek"), text: $store.searchText)
|
||||
.font(AppTheme.searchFont)
|
||||
.foregroundStyle(AppTheme.title)
|
||||
@@ -529,14 +514,8 @@ struct WorkspaceView: View {
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(active ? AppTheme.filterActive : AppTheme.border, lineWidth: 1))
|
||||
.accessibilityLabel(label)
|
||||
// Quiet ring when filter turns on (lower opacity than before — less "showy" than Android)
|
||||
.changeEffect(
|
||||
.ping(shape: Circle(), style: AppTheme.filterActive.opacity(0.22), count: 1),
|
||||
value: active,
|
||||
isEnabled: active
|
||||
)
|
||||
}
|
||||
.buttonStyle(FilterChipButtonStyle(active: active))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
/// Bottom tab chrome:
|
||||
@@ -576,7 +555,6 @@ struct WorkspaceView: View {
|
||||
createLongPressConsumed = false
|
||||
return
|
||||
}
|
||||
createTapCount += 1
|
||||
openCreateNote()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
@@ -593,7 +571,7 @@ struct WorkspaceView: View {
|
||||
)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(CreateButtonPressStyle())
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
LongPressGesture(minimumDuration: 0.45)
|
||||
.onEnded { _ in
|
||||
@@ -603,7 +581,6 @@ struct WorkspaceView: View {
|
||||
showCreateChoice = true
|
||||
}
|
||||
)
|
||||
.edgeEverCreatePing(count: createTapCount)
|
||||
.disabled(!canCreate)
|
||||
.accessibilityLabel(env.preferences.t("新建笔记", en: "New note", pl: "Nowa notatka"))
|
||||
.accessibilityHint(env.preferences.t("长按可从模板新建", en: "Long-press to create from a template", pl: "Przytrzymaj, aby utworzyć z szablonu"))
|
||||
|
||||
+3
-5
@@ -23,15 +23,13 @@ Android remains Expo / React Native in `apps/mobile`. Store builds for iOS use t
|
||||
|
||||
## Motion / animations
|
||||
|
||||
Motion stack: **SwiftUI Animation** (timing / springs) + **[Pow](https://github.com/EmergeTools/Pow)** (Emerge).
|
||||
Motion stack: **SwiftUI Animation** (short easing transitions) + **[Pow](https://github.com/EmergeTools/Pow)** (Emerge) for focused feedback.
|
||||
|
||||
| Effect | Where |
|
||||
|--------|--------|
|
||||
| **Jump** (physics hop + squash) | First list open (whole list); create/edit return on that memo |
|
||||
| **Boing** (elastic drop-in) | Staggered first-paint cards |
|
||||
| **Ping** | Create button / returned memo highlight |
|
||||
| Static list cards and press targets | List entry, create/edit return, card / create / filter presses |
|
||||
| Short easing transitions | Search, filters, settings, loading states |
|
||||
| **Shine / Shake / Haptic** | Pin success, errors, selection |
|
||||
| SwiftUI scale press | Card / create / filter finger-down |
|
||||
|
||||
Curves & wrappers: `EdgeEver/DesignSystem/Motion.swift`.
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { MemoFilterMode, MemoSortMode } from "@edgeever/client";
|
||||
import { DEFAULT_MEMO_TITLE, getMemoListTimestamp, getNotebookDescendantMemoCount, type MemoSummary, type Notebook } from "@edgeever/shared";
|
||||
import { MOBILE_UI_METRICS, toggleMobileMemoFilterMode } from "@edgeever/shared/mobile-ui";
|
||||
import { FlatList, Platform, RefreshControl, View } from "react-native";
|
||||
import Animated, { FadeInDown, FadeOutUp, LinearTransition, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
|
||||
import { ActivityIndicator, Check, ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Plus, RotateCcw, Search, Sparkles, Tag, X } from "../components/icons";
|
||||
import { Pressable, Text, TextInput } from "../components/LocalizedText";
|
||||
import type { MobileBootstrapProgress } from "../lib/local-mirror";
|
||||
@@ -443,21 +442,12 @@ const MemoCard = memo(function MemoCard({
|
||||
? (resolvedLocale !== "zh-CN" ? "Created" : "创建")
|
||||
: (resolvedLocale !== "zh-CN" ? "Updated" : "更新");
|
||||
const handledLongPressRef = useRef(false);
|
||||
const pressScale = useSharedValue(1);
|
||||
const pressAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: pressScale.value }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(260).springify().damping(18)}
|
||||
exiting={FadeOutUp.duration(220)}
|
||||
layout={LinearTransition.duration(220)}
|
||||
<View
|
||||
style={[
|
||||
styles.memoCard,
|
||||
listDensity === "compact" && styles.memoCardCompact,
|
||||
selected && styles.memoCardSelected,
|
||||
pressAnimatedStyle,
|
||||
]}
|
||||
>
|
||||
{selectionMode ? (
|
||||
@@ -488,12 +478,6 @@ const MemoCard = memo(function MemoCard({
|
||||
}
|
||||
onPress();
|
||||
}}
|
||||
onPressIn={() => {
|
||||
pressScale.value = withTiming(0.985, { duration: 100 });
|
||||
}}
|
||||
onPressOut={() => {
|
||||
pressScale.value = withTiming(1, { duration: 160 });
|
||||
}}
|
||||
style={[styles.memoCardContent, listDensity === "compact" && styles.memoCardContentCompact, selectionMode && styles.memoCardContentWithSelection]}
|
||||
>
|
||||
<View style={styles.memoCardTop}>
|
||||
@@ -518,7 +502,7 @@ const MemoCard = memo(function MemoCard({
|
||||
))}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}, (previous, next) =>
|
||||
previous.memo === next.memo &&
|
||||
|
||||
Reference in new issue
Block a user