Remove bouncy motion from mobile apps

This commit is contained in:
tianma-if committed 2026-10-07 21:26:53 +08:00
1 parent 49de42a430
commit 891909bfb4
8 files changed
+44 -427

No files matched your search

+5 -261
View File
@@ -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
View File
@@ -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`.
+2 -18
View File
@@ -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 &&