diff --git a/apps/ios/EdgeEver/DesignSystem/Motion.swift b/apps/ios/EdgeEver/DesignSystem/Motion.swift index 4ecb2de5..e43bb736 100644 --- a/apps/ios/EdgeEver/DesignSystem/Motion.swift +++ b/apps/ios/EdgeEver/DesignSystem/Motion.swift @@ -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(_ 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 - ) - } } diff --git a/apps/ios/EdgeEver/Features/Auth/LoginView.swift b/apps/ios/EdgeEver/Features/Auth/LoginView.swift index 039d545f..ebc9f598 100644 --- a/apps/ios/EdgeEver/Features/Auth/LoginView.swift +++ b/apps/ios/EdgeEver/Features/Auth/LoginView.swift @@ -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) } diff --git a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift index 0bdc373a..e3b288f3 100644 --- a/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/MemoEditView.swift @@ -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() diff --git a/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift index a78c7e63..152e7a5c 100644 --- a/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/NotesListView.swift @@ -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 diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift index 26a5663c..ef8abe95 100644 --- a/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceStore.swift @@ -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? 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 { diff --git a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift index e98fcd98..b67de174 100644 --- a/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift +++ b/apps/ios/EdgeEver/Features/Workspace/WorkspaceView.swift @@ -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")) diff --git a/apps/ios/README.md b/apps/ios/README.md index 0f2d18c2..5aa80b95 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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`. diff --git a/apps/mobile/src/screens/WorkspaceNotesView.tsx b/apps/mobile/src/screens/WorkspaceNotesView.tsx index 7c436887..62f0ea4e 100644 --- a/apps/mobile/src/screens/WorkspaceNotesView.tsx +++ b/apps/mobile/src/screens/WorkspaceNotesView.tsx @@ -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 ( - {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]} > @@ -518,7 +502,7 @@ const MemoCard = memo(function MemoCard({ ))} - + ); }, (previous, next) => previous.memo === next.memo &&