|
@@ -10,10 +10,18 @@
|
|
|
@touchstart="onTouchStart"
|
|
@touchstart="onTouchStart"
|
|
|
@touchend="onTouchEnd"
|
|
@touchend="onTouchEnd"
|
|
|
@touchcancel="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>
|
|
<text class="feed-item-text">{{ currentItem.text }}</text>
|
|
|
<view class="feed-item-dot" v-if="currentItem.unread"></view>
|
|
<view class="feed-item-dot" v-if="currentItem.unread"></view>
|
|
|
</view>
|
|
</view>
|
|
@@ -51,6 +59,7 @@
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script>
|
|
<script>
|
|
|
|
|
+// 动态类型 → 图标 / 类型名(详情弹层使用)
|
|
|
var TYPE_META = {
|
|
var TYPE_META = {
|
|
|
energy: { icon: '⚡', label: '能量变动' },
|
|
energy: { icon: '⚡', label: '能量变动' },
|
|
|
points: { icon: '🪙', label: '积分变动' },
|
|
points: { icon: '🪙', label: '积分变动' },
|
|
@@ -62,21 +71,27 @@ var TYPE_META = {
|
|
|
}
|
|
}
|
|
|
var DEFAULT_META = { icon: '📣', label: '消息' }
|
|
var DEFAULT_META = { icon: '📣', label: '消息' }
|
|
|
|
|
|
|
|
|
|
+// 入场/出场动画时长(ms),需与 CSS keyframes 的 duration 保持一致
|
|
|
|
|
+var ANIM_MS = 400
|
|
|
|
|
+
|
|
|
export default {
|
|
export default {
|
|
|
name: 'FamilyFeed',
|
|
name: 'FamilyFeed',
|
|
|
props: {
|
|
props: {
|
|
|
items: { type: Array, default: function() { return [] } },
|
|
items: { type: Array, default: function() { return [] } },
|
|
|
notices: { type: Array, default: function() { return [] } },
|
|
notices: { type: Array, default: function() { return [] } },
|
|
|
showHeader: { type: Boolean, default: true },
|
|
showHeader: { type: Boolean, default: true },
|
|
|
|
|
+ // 每条动态展示时长(毫秒)
|
|
|
interval: { type: Number, default: 3000 }
|
|
interval: { type: Number, default: 3000 }
|
|
|
},
|
|
},
|
|
|
data: function() {
|
|
data: function() {
|
|
|
return {
|
|
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,
|
|
currentIndex: 0,
|
|
|
_touchPaused: false,
|
|
_touchPaused: false,
|
|
|
detail: null,
|
|
detail: null,
|
|
@@ -85,6 +100,7 @@ export default {
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
computed: {
|
|
computed: {
|
|
|
|
|
+ // 合并 items + notices,去重(父页已通过 feedItems 过滤已查看项)
|
|
|
visibleList: function() {
|
|
visibleList: function() {
|
|
|
var list = [], seen = {}, i
|
|
var list = [], seen = {}, i
|
|
|
var items = this.items || []
|
|
var items = this.items || []
|
|
@@ -123,16 +139,10 @@ export default {
|
|
|
if (!list || list.length === 0) return null
|
|
if (!list || list.length === 0) return null
|
|
|
return list[this.currentIndex % list.length]
|
|
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 {}
|
|
return {}
|
|
|
},
|
|
},
|
|
@@ -154,17 +164,21 @@ export default {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
if (this.currentIndex >= newList.length) { this.currentIndex = 0 }
|
|
if (this.currentIndex >= newList.length) { this.currentIndex = 0 }
|
|
|
- this.state = 'entering'
|
|
|
|
|
|
|
+ // 列表变化时强制重播入场动画
|
|
|
|
|
+ this.cycleKey++
|
|
|
|
|
+ this.phase = 'in'
|
|
|
this._startAutoPlay()
|
|
this._startAutoPlay()
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
mounted: function() { this._startAutoPlay() },
|
|
mounted: function() { this._startAutoPlay() },
|
|
|
beforeDestroy: function() { this._stopAutoPlay() },
|
|
beforeDestroy: function() { this._stopAutoPlay() },
|
|
|
methods: {
|
|
methods: {
|
|
|
|
|
+ // ─── 状态机(定时器链驱动) ───────────────────────────────────
|
|
|
|
|
+ // 元素动画由 keyframes 自行播放;这里只在正确时机切换 phase
|
|
|
_startAutoPlay: function() {
|
|
_startAutoPlay: function() {
|
|
|
this._stopAutoPlay()
|
|
this._stopAutoPlay()
|
|
|
var self = this
|
|
var self = this
|
|
|
- this._timer = setTimeout(function() { self._tick() }, 50)
|
|
|
|
|
|
|
+ this._timer = setTimeout(function() { self._tick() }, ANIM_MS + 50)
|
|
|
},
|
|
},
|
|
|
_stopAutoPlay: function() {
|
|
_stopAutoPlay: function() {
|
|
|
if (this._timer) { clearTimeout(this._timer); this._timer = null }
|
|
if (this._timer) { clearTimeout(this._timer); this._timer = null }
|
|
@@ -176,27 +190,23 @@ export default {
|
|
|
self._timer = setTimeout(function() { self._tick() }, 200)
|
|
self._timer = setTimeout(function() { self._tick() }, 200)
|
|
|
return
|
|
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() },
|
|
onTouchStart: function() { this._touchPaused = true; this._stopAutoPlay() },
|
|
|
onTouchEnd: function() { this._touchPaused = false; this._startAutoPlay() },
|
|
onTouchEnd: function() { this._touchPaused = false; this._startAutoPlay() },
|
|
|
onItemTap: function(item) {
|
|
onItemTap: function(item) {
|
|
@@ -266,7 +276,7 @@ export default {
|
|
|
position: relative;
|
|
position: relative;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/* 条目:绝对定位叠放,CSS transition 驱动翻转 */
|
|
|
|
|
|
|
+/* 条目基础样式:绝对定位,占满视口一行 */
|
|
|
.feed-item {
|
|
.feed-item {
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
top: 0; left: 0; right: 0;
|
|
top: 0; left: 0; right: 0;
|
|
@@ -275,12 +285,31 @@ export default {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
flex-direction: row;
|
|
flex-direction: row;
|
|
|
align-items: center;
|
|
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 {
|
|
.feed-item-text {
|
|
|
flex: 1; min-width: 0;
|
|
flex: 1; min-width: 0;
|
|
@@ -306,7 +335,7 @@ export default {
|
|
|
}
|
|
}
|
|
|
.feed-empty-text { font-size: 24rpx; color: #ccc; }
|
|
.feed-empty-text { font-size: 24rpx; color: #ccc; }
|
|
|
|
|
|
|
|
-/* ===== 详情弹层 ===== */
|
|
|
|
|
|
|
+/* ===== 消息详情弹层 ===== */
|
|
|
.feed-detail-mask {
|
|
.feed-detail-mask {
|
|
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
|
background: rgba(0,0,0,0.5); z-index: 2000;
|
|
background: rgba(0,0,0,0.5); z-index: 2000;
|
|
@@ -360,4 +389,4 @@ export default {
|
|
|
display: flex; align-items: center; justify-content: center;
|
|
display: flex; align-items: center; justify-content: center;
|
|
|
}
|
|
}
|
|
|
.feed-detail-btn-text { font-size: 28rpx; color: #fff; font-weight: bold; }
|
|
.feed-detail-btn-text { font-size: 28rpx; color: #fff; font-weight: bold; }
|
|
|
-</style>
|
|
|
|
|
|
|
+</style>
|