添加动画

This commit is contained in:
thofx
2023-07-22 16:43:22 +08:00
parent 018778488a
commit 4d67126f0e
5 changed files with 151 additions and 10 deletions
+87
View File
@@ -26,6 +26,8 @@ function changeTheme() {
globalTheme.name.value = nextTheme globalTheme.name.value = nextTheme
savedTheme.value = nextTheme savedTheme.value = nextTheme
localStorage.setItem('theme', nextTheme) localStorage.setItem('theme', nextTheme)
themeTransition()
} }
// Update icon if theme is changed from other sources // Update icon if theme is changed from other sources
@@ -40,6 +42,75 @@ watch(
onMounted(() => { onMounted(() => {
globalTheme.name.value = savedTheme.value globalTheme.name.value = savedTheme.value
}) })
function hasScrollbar(el?: Element | null) {
if (!el || el.nodeType !== Node.ELEMENT_NODE)
return false
const style = window.getComputedStyle(el)
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
}
function themeTransition() {
const x = performance.now()
for (let i = 0; i++ < 1e7; (i << 9) & ((9 % 9) * 9 + 9));
const cost = performance.now() - x
if (cost > 10)
return
const el: HTMLElement = document.querySelector('[data-v-app]')!
const children = el.querySelectorAll('*') as NodeListOf<HTMLElement>
children.forEach((el) => {
if (hasScrollbar(el)) {
el.dataset.scrollX = String(el.scrollLeft)
el.dataset.scrollY = String(el.scrollTop)
}
})
const copy = el.cloneNode(true) as HTMLElement
copy.classList.add('app-copy')
const rect = el.getBoundingClientRect()
copy.style.top = `${rect.top}px`
copy.style.left = `${rect.left}px`
copy.style.width = `${rect.width}px`
copy.style.height = `${rect.height}px`
const targetEl = document.activeElement as HTMLElement
const targetRect = targetEl.getBoundingClientRect()
const left = targetRect.left + targetRect.width / 2 + window.scrollX
const top = targetRect.top + targetRect.height / 2 + window.scrollY
el.style.setProperty('--clip-pos', `${left}px ${top}px`)
el.style.removeProperty('--clip-size')
nextTick(() => {
el.classList.add('app-transition')
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.setProperty('--clip-size', `${Math.hypot(window.innerWidth, window.innerHeight)}px`)
})
})
})
document.body.append(copy)
; (copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach((el) => {
el.scrollLeft = +el.dataset.scrollX!
el.scrollTop = +el.dataset.scrollY!
})
function onTransitionend(e: TransitionEvent) {
if (e.target === e.currentTarget) {
copy.remove()
el.removeEventListener('transitionend', onTransitionend)
el.removeEventListener('transitioncancel', onTransitionend)
el.classList.remove('app-transition')
el.style.removeProperty('--clip-size')
el.style.removeProperty('--clip-pos')
}
}
el.addEventListener('transitionend', onTransitionend)
el.addEventListener('transitioncancel', onTransitionend)
}
</script> </script>
<template> <template>
@@ -47,3 +118,19 @@ onMounted(() => {
<VIcon :icon="props.themes[currentThemeIndex].icon" /> <VIcon :icon="props.themes[currentThemeIndex].icon" />
</IconBtn> </IconBtn>
</template> </template>
<style lang="sass">
// Theme transition
.app-copy
position: fixed !important
z-index: -1 !important
pointer-events: none !important
contain: size style !important
overflow: clip !important
.app-transition
--clip-size: 0
--clip-pos: 0 0
clip-path: circle(var(--clip-size) at var(--clip-pos))
transition: clip-path .35s ease-out
</style>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Transition } from 'vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import VerticalNav from '@layouts/components/VerticalNav.vue' import VerticalNav from '@layouts/components/VerticalNav.vue'
@@ -50,7 +51,9 @@ export default defineComponent({
const main = h( const main = h(
'main', 'main',
{ class: 'layout-page-content' }, { class: 'layout-page-content' },
h('div', { class: 'page-content-container' }, slots.default?.()), h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true },
h('section', { class: 'page-content-container' }, slots.default?.()),
),
) )
// 👉 Footer // 👉 Footer
+2 -2
View File
@@ -6,11 +6,11 @@
html { html {
min-height: calc(100% + env(safe-area-inset-top)); min-height: calc(100% + env(safe-area-inset-top));
background: rgb(var(--v-theme-background)); //background: rgb(var(--v-theme-background));
} }
body { body {
background: rgb(var(--v-theme-background)); //background: rgb(var(--v-theme-background));
overscroll-behavior-y: contain; overscroll-behavior-y: contain;
--webkit-overflow-scrolling: touch; --webkit-overflow-scrolling: touch;
} }
+24 -7
View File
@@ -1,11 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { PropType } from 'vue' import type { PropType, Ref } from 'vue'
import { useToast } from 'vue-toast-notification' import { useToast } from 'vue-toast-notification'
import { formatSeason } from '@/@core/utils/formatters' import { formatSeason } from '@/@core/utils/formatters'
import api from '@/api' import api from '@/api'
import { doneNProgress, startNProgress } from '@/api/nprogress' import { doneNProgress, startNProgress } from '@/api/nprogress'
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types' import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
import router from '@/router' import router from '@/router'
import noImage from '@images/no-image.jpeg'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -392,13 +393,16 @@ const seasonsHeaders = [
] ]
// 计算图片地址 // 计算图片地址
function getImgUrl(url: string) { const getImgUrl: Ref<string> = computed(() => {
if (imageLoadError.value)
return noImage
const url = props.media?.poster_path || noImage
// 如果地址中包含douban则使用中转代理 // 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com')) if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}` return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`
return url return url
} })
</script> </script>
<template> <template>
@@ -408,15 +412,24 @@ function getImgUrl(url: string) {
v-bind="hover.props" v-bind="hover.props"
:height="props.height" :height="props.height"
:width="props.width" :width="props.width"
:loading="!isImageLoaded"
class="outline-none shadow ring-gray-500" class="outline-none shadow ring-gray-500"
:class="{ :class="{
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering, 'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
'ring-1': isImageLoaded, 'ring-1': isImageLoaded,
}" }"
> >
<template #loader="{ isActive }">
<v-progress-linear
:active="isActive"
color="deep-purple"
height="4"
indeterminate
/>
</template>
<VImg <VImg
aspect-ratio="2/3" aspect-ratio="2/3"
:src="getImgUrl(props.media?.poster_path || '')" :src="getImgUrl"
class="object-cover aspect-w-2 aspect-h-3" class="object-cover aspect-w-2 aspect-h-3"
:class="hover.isHovering ? 'on-hover' : ''" :class="hover.isHovering ? 'on-hover' : ''"
cover cover
@@ -424,12 +437,16 @@ function getImgUrl(url: string) {
@error="imageLoadError = true" @error="imageLoadError = true"
> >
<template #placeholder> <template #placeholder>
<div class="relative animate-pulse bg-gray-300 w-full"> <div class="d-flex align-center justify-center fill-height">
<div class="w-full h-full" /> <v-progress-circular
color="grey-lighten-4"
indeterminate
/>
</div> </div>
</template> </template>
<!-- 类型角标 --> <!-- 类型角标 -->
<VChip <VChip
v-show="isImageLoaded"
variant="elevated" variant="elevated"
size="small" size="small"
:class="getChipColor(props.media?.type || '')" :class="getChipColor(props.media?.type || '')"
@@ -441,7 +458,7 @@ function getImgUrl(url: string) {
<ExistIcon v-if="isExists" /> <ExistIcon v-if="isExists" />
<!-- 评分角标 --> <!-- 评分角标 -->
<VChip <VChip
v-if="props.media?.vote_average && !isExists" v-if="isImageLoaded && props.media?.vote_average && !isExists"
variant="elevated" variant="elevated"
size="small" size="small"
:class="getChipColor('rating')" :class="getChipColor('rating')"
+34
View File
@@ -50,3 +50,37 @@
max-height: calc(100% - env(safe-area-inset-top)); max-height: calc(100% - env(safe-area-inset-top));
width: 100%; width: 100%;
} }
/* router view transition fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* router view transition fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.6s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-45px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(45px);
}