Преглед на файлове

fix(feed): 改用CSS关键帧动画确保翻动方向正确(从下进入、从上退出)

liaoxg преди 1 седмица
родител
ревизия
f7f2a01e26
променени са 1 файла, в които са добавени 76 реда и са изтрити 47 реда
  1. 76 47
      cfc-frontend/components/FamilyFeed.vue

+ 76 - 47
cfc-frontend/components/FamilyFeed.vue

@@ -10,10 +10,18 @@
             @touchstart="onTouchStart"
             @touchend="onTouchEnd"
             @touchcancel="onTouchEnd">
-        <view class="feed-item"
-              :class="itemClass"
-              :style="itemInlineStyle"
-              @click="onItemTap(currentItem)">
+        <!--
+          单元素 + @keyframes 关键帧动画:方向由 keyframes 的 from/to 固定,
+          不依赖 CSS transition 的起点推导,换条时用 cycleKey 强制重建元素(动画必然从 from 重播)。
+          - feed-anim-in  : from translateY(100%)(底部) → to translateY(0)(从下往上进入)
+          - feed-anim-out : from translateY(0) → to translateY(-100%)(顶部)(向上翻出)
+        -->
+        <view
+          :key="cycleKey"
+          class="feed-item"
+          :class="phase === 'out' ? 'feed-anim-out' : 'feed-anim-in'"
+          :style="animPauseStyle"
+          @click="onItemTap(currentItem)">
           <text class="feed-item-text">{{ currentItem.text }}</text>
           <view class="feed-item-dot" v-if="currentItem.unread"></view>
         </view>
@@ -51,6 +59,7 @@
 </template>
 
 <script>
+// 动态类型 → 图标 / 类型名(详情弹层使用)
 var TYPE_META = {
   energy:       { icon: '⚡', label: '能量变动' },
   points:       { icon: '🪙', label: '积分变动' },
@@ -62,21 +71,27 @@ var TYPE_META = {
 }
 var DEFAULT_META = { icon: '📣', label: '消息' }
 
+// 入场/出场动画时长(ms),需与 CSS keyframes 的 duration 保持一致
+var ANIM_MS = 400
+
 export default {
   name: 'FamilyFeed',
   props: {
     items: { type: Array, default: function() { return [] } },
     notices: { type: Array, default: function() { return [] } },
     showHeader: { type: Boolean, default: true },
+    // 每条动态展示时长(毫秒)
     interval: { type: Number, default: 3000 }
   },
   data: function() {
     return {
-      // state: 'entering' | 'visible' | 'leaving'
-      // entering: jump to translateY(100%), next frame animates to 0
-      // visible: displayed for interval ms, then animates out
-      // leaving: animates to translateY(-100%), then advance to next item
-      state: 'entering',
+      // phase: 'in' | 'show' | 'out'
+      // in   : 正在从底部翻入(feed-anim-in 动画中)
+      // show : 已翻入到中间,停留 interval 毫秒
+      // out  : 正在向上翻出(feed-anim-out 动画中)
+      phase: 'in',
+      // 切条时 +1 强制重建元素,保证 keyframes 动画从 from 重播
+      cycleKey: 0,
       currentIndex: 0,
       _touchPaused: false,
       detail: null,
@@ -85,6 +100,7 @@ export default {
     }
   },
   computed: {
+    // 合并 items + notices,去重(父页已通过 feedItems 过滤已查看项)
     visibleList: function() {
       var list = [], seen = {}, i
       var items = this.items || []
@@ -123,16 +139,10 @@ export default {
       if (!list || list.length === 0) return null
       return list[this.currentIndex % list.length]
     },
-    itemClass: function() {
-      if (this.state === 'visible') return 'feed-item-visible'
-      if (this.state === 'leaving') return 'feed-item-leaving'
-      return 'feed-item-entering'
-    },
-    itemInlineStyle: function() {
-      // leaving 时保持 transition:none,防止从顶部跳到下一条时产生反向动画
-      // entering 时也保持 none,让下一帧再恢复 transition 做入场动画
-      if (this.state === 'entering' || this.state === 'leaving') {
-        return { transition: 'none' }
+    // 触摸/弹层打开时暂停动画,避免后台空转
+    animPauseStyle: function() {
+      if (this._touchPaused || this.detailVisible) {
+        return { animationPlayState: 'paused' }
       }
       return {}
     },
@@ -154,17 +164,21 @@ export default {
         return
       }
       if (this.currentIndex >= newList.length) { this.currentIndex = 0 }
-      this.state = 'entering'
+      // 列表变化时强制重播入场动画
+      this.cycleKey++
+      this.phase = 'in'
       this._startAutoPlay()
     }
   },
   mounted: function() { this._startAutoPlay() },
   beforeDestroy: function() { this._stopAutoPlay() },
   methods: {
+    // ─── 状态机(定时器链驱动) ───────────────────────────────────
+    // 元素动画由 keyframes 自行播放;这里只在正确时机切换 phase
     _startAutoPlay: function() {
       this._stopAutoPlay()
       var self = this
-      this._timer = setTimeout(function() { self._tick() }, 50)
+      this._timer = setTimeout(function() { self._tick() }, ANIM_MS + 50)
     },
     _stopAutoPlay: function() {
       if (this._timer) { clearTimeout(this._timer); this._timer = null }
@@ -176,27 +190,23 @@ export default {
         self._timer = setTimeout(function() { self._tick() }, 200)
         return
       }
-      if (self.state === 'entering') {
-        self._timer = setTimeout(function() {
-          if (self._touchPaused || self.detailVisible) { self._tick(); return }
-          self.state = 'visible'
-          self._timer = setTimeout(function() { self._tick() }, 400)
-        }, 50)
-      } else if (self.state === 'visible') {
-        self._timer = setTimeout(function() {
-          if (self._touchPaused || self.detailVisible) { self._tick(); return }
-          self.state = 'leaving'
-          self._timer = setTimeout(function() { self._tick() }, 400)
-        }, self.interval)
-      } else if (self.state === 'leaving') {
-        self._timer = setTimeout(function() {
-          if (self._touchPaused || self.detailVisible) { self._tick(); return }
-          self.currentIndex = (self.currentIndex + 1) % (self.visibleList.length || 1)
-          self.state = 'entering'
-          self._tick()
-        }, 400)
+      if (self.phase === 'in') {
+        // 翻入动画结束,进入展示期
+        self.phase = 'show'
+        self._timer = setTimeout(function() { self._tick() }, self.interval)
+      } else if (self.phase === 'show') {
+        // 展示期结束,开始向上翻出
+        self.phase = 'out'
+        self._timer = setTimeout(function() { self._tick() }, ANIM_MS + 50)
+      } else if (self.phase === 'out') {
+        // 翻出结束:切到下一条,重建元素重新翻入
+        self.currentIndex = (self.currentIndex + 1) % (self.visibleList.length || 1)
+        self.cycleKey++
+        self.phase = 'in'
+        self._startAutoPlay()
       }
     },
+    // ─── 用户交互 ───────────────────────────────────────────────────
     onTouchStart: function() { this._touchPaused = true; this._stopAutoPlay() },
     onTouchEnd:   function() { this._touchPaused = false; this._startAutoPlay() },
     onItemTap:    function(item) {
@@ -266,7 +276,7 @@ export default {
   position: relative;
 }
 
-/* 条目:绝对定位叠放,CSS transition 驱动翻转 */
+/* 条目基础样式:绝对定位,占满视口一行 */
 .feed-item {
   position: absolute;
   top: 0; left: 0; right: 0;
@@ -275,12 +285,31 @@ export default {
   display: flex;
   flex-direction: row;
   align-items: center;
-  transition: transform 0.4s ease-in-out;
 }
 
-.feed-item-entering { transform: translateY(100%); }
-.feed-item-visible  { transform: translateY(0); }
-.feed-item-leaving  { transform: translateY(-100%); }
+/*
+  关键帧动画方向固定,不受初始样式影响:
+  - 入场(从下往上):底部 translateY(100%) → 中间 translateY(0)
+  - 出场(向上翻出):中间 translateY(0) → 顶部 translateY(-100%)
+  forwards 保持动画结束后的位置(元素停在 translateY(0) / translateY(-100%))
+*/
+.feed-anim-in {
+  animation: feedSlideUpIn 0.4s ease-in-out forwards;
+}
+
+.feed-anim-out {
+  animation: feedSlideUpOut 0.4s ease-in-out forwards;
+}
+
+@keyframes feedSlideUpIn {
+  from { transform: translateY(100%); }
+  to   { transform: translateY(0); }
+}
+
+@keyframes feedSlideUpOut {
+  from { transform: translateY(0); }
+  to   { transform: translateY(-100%); }
+}
 
 .feed-item-text {
   flex: 1; min-width: 0;
@@ -306,7 +335,7 @@ export default {
 }
 .feed-empty-text { font-size: 24rpx; color: #ccc; }
 
-/* ===== 详情弹层 ===== */
+/* ===== 消息详情弹层 ===== */
 .feed-detail-mask {
   position: fixed; top: 0; left: 0; right: 0; bottom: 0;
   background: rgba(0,0,0,0.5); z-index: 2000;
@@ -360,4 +389,4 @@ export default {
   display: flex; align-items: center; justify-content: center;
 }
 .feed-detail-btn-text { font-size: 28rpx; color: #fff; font-weight: bold; }
-</style>
+</style>