D09 - 打开第一扇窗

现在有资料,只差介面了。

建立 base-window 组件

虽然每个视窗功能都不同,但是视窗外框功能都一样,所以我们建立 base-window.vue 组件透过 slot 保留弹性,其他特定功能的卡片只要引入 base-window.vue 并透过 slot 就可以加入不同的功能。

预期长这样:

D09.png

base-window.vue 功能需求:

  • title bar

    • 拖动可以自由移动视窗位置。
    • 左侧 ICON 可自订。
    • 中间文字可自订。
    • 右侧关闭按钮可关闭视窗。
  • 视窗内容可以任意抽换。

    使用 slot 实现。

建立 src\components\base-window.vue

src\components\base-window.vue <template lang="pug">

.base-window(
  @click.stop='handleClick',
  :style='style',
  :class='classes',
  @touchstart.stop,
  @contextmenu.stop
)
  q-bar.base-window-header-bar(v-touch-pan.prevent.mouse='handleMove')
    q-icon(:name='headerIcon', :color='color')
    q-space
    .base-window-title.text-shadow {{ title }}
    q-space
    q-btn(
      @click='handleClose',
      icon='r_close',
      dense,
      flat,
      rounded,
      color='grey-5'
    )
  .base-window-body(:class='bodyClass')
    slot

样式部分预期设计 Focus 效果,利用阴影呈现高低落差,所以在 quasar.variables.sass 建立阴影样式变数。

src\styles\quasar.variables.sass

// ...

$unfocus-shadow: 0 0px 20px rgba(#000, 0.05), 0 2.8px 2.2px -30px rgba(0, 0, 0, 0.02),0 6.7px 5.3px -30px rgba(0, 0, 0, 0.028),0 12.5px 10px -30px rgba(0, 0, 0, 0.035),0 22.3px 17.9px -30px rgba(0, 0, 0, 0.042),0 41.8px 33.4px -30px rgba(0, 0, 0, 0.05),0 100px 80px -30px rgba(0, 0, 0, 0.07)
$focus-shadow: 0 0px 20px rgba(#000, 0.05),0 2.8px 2.2px rgba(0, 0, 0, 0.02),0 6.7px 5.3px rgba(0, 0, 0, 0.028),0 12.5px 10px rgba(0, 0, 0, 0.035),0 22.3px 17.9px rgba(0, 0, 0, 0.042),0 41.8px 33.4px rgba(0, 0, 0, 0.05),0 100px 80px rgba(0, 0, 0, 0.07)

@import '~quasar-variables-styl'

src\components\base-window.vue <style scoped lang="sass">

@import '@/styles/quasar.variables.sass'

.base-window
  position: fixed
  min-width: 200px
  min-height: 100px
  overflow: hidden
  transition-duration: 0.5s
  transform: translateZ(0px)
  transition-timing-function: cubic-bezier(0.83, 0, 0.17, 1)
  box-shadow: $unfocus-shadow
  border-radius: $border-radius-m
  background: rgba(white, 0.8)
  backdrop-filter: blur(4px)
  &.moving
    transition: top 0s, left 0s, transform 0.5s, box-shadow 0.5s

  .base-window-header-bar
    height: auto
    padding: 20px
    padding-bottom: 14px
    cursor: move
    background: none
    color: $grey-8
    .base-window-title
      font-size: 14px
      user-select: none
      margin: 0px
      position: relative
      font-weight: 900
      transition-duration: 0.4s
      letter-spacing: 1px

  .base-window-body
    position: relative

src\components\base-window.vue <script>

export default {
  name: 'BaseWindow',
  components: {},
  props: {
    // 视窗起始位置
    pos: {
      type: Object,
      default() {
        return {
          x: 0,
          y: 0,
        };
      },
    },

    // 可以额外增加 class
    bodyClass: {
      type: String,
      default: '',
    },

    // title bar 文字
    title: {
      type: String,
      default: 'title',
    },

    // title bar icon 名称
    headerIcon: {
      type: String,
      default: 'r_dashboard',
    },

    // title bar icon 颜色
    headerIconColor: {
      type: String,
      default: 'blue-grey-4',
    },
  },
  data() {
    return {
      // 目前视窗移动量
      offset: {
        x: 0,
        y: 0,
      },

      status: {
        isMoving: false,
      },
    };
  },
  computed: {
    style() {
      const xSum = this.offset.x;
      const ySum = this.offset.y;

      const style = {
        zIndex: this.zIndex,
        top: `${ySum}px`,
        left: `${xSum}px`,
      };

      return style;
    },
    classes() {
      const classes = [];

      if (this.status.isMoving) {
        classes.push('moving');
      }

      return classes;
    },
  },
  watch: {},
  created() {
    this.offset.x = this.pos.x;
    this.offset.y = this.pos.y;
  },
  mounted() {},
  methods: {
    handleClick() {},
    handleClose() {},

    /** 处理拖动事件
     *
     * 使用 Quasar v-touch-pan 指令实现
     *
     * [参考资料](https://v1.quasar.dev/vue-directives/touch-pan)
     */
    handleMove({ isFinal, delta }) {
      // console.log(`[ handleMove ] delta  : `, delta);
      this.status.isMoving = !isFinal;

      // 累加每次移动变化量。
      this.offset.x += delta.x;
      this.offset.y += delta.y;
    },
  },
};

最後回到 app.vue,直接将 base-window 加入 HTML 看看效果。

src\app.vue

<template lang="pug">
.screen
  .info.font-orbitron(v-if='firmwareName')
    | {{ firmwareName }} - v{{ ver }}

  dialog-system-setting

  base-window(:pos='{ x: 50, y: 50 }')
</template>

<style lang="sass">
// ...
</style>

<script>
// ...

import BaseWindow from '@/components/base-window.vue';

export default {
  name: 'App',
  components: {
    'dialog-system-setting': DialogSystemSetting,
    'base-window': BaseWindow,
  },
	// ...
};
</script>

D09 - 建立 base-window.vue 组件.gif

看起来真不错 (≖‿ゝ≖)✧

建立范例视窗

接下来实际建立一个真正的视窗。

在 Vuex 中建立 window 模组,储存目前显示视窗与视窗相关数值。

设计 Window 资料格式

  • component:组件名称

    不同的视窗组件名称。

  • id:视窗 ID

    唯一 ID,用於识别视窗。

  • focusAt:聚焦时间

    判断视窗重叠关系

src\store\modules\window.store.js

/**
 * 管理视窗相关资料
 */

/**
 * @typedef {import('vuex').Module} Module
 * 
 * @typedef {import('@/types/type').Window} Window
 */

/** @type {Module} */
const self = {
  namespaced: true,
  state: () => ({
		/** @type {Window[]} */
    list: [],
  }),
  mutations: {
  },
  actions: {
  },
  getters: {
  },
  modules: {
  },
};

export default self;

并在 Vuex 引入。

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

import core from './modules/core.store';
import board from './modules/board.store';
import window from './modules/window.store';

export default new Vuex.Store({
  // ...
  modules: {
    core, board, window
  },
});

接着增加「新增、删除视窗」的功能。

每个视窗要建立一个专属的 ID,所以先我们在 utils 新增 getRandomString()

/** 取得随机长度字串
 * @param {number} len 指定字串长度
 * @param {String} [charSet] 指定组成字符
 * @return {string}
 */
export function getRandomString(len = 5, charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789') {
  let randomString = '';
  for (let i = 0; i < len; i++) {
    const randomPoz = Math.floor(Math.random() * charSet.length);
    randomString += charSet.substring(randomPoz, randomPoz + 1);
  }
  return randomString;
}

回到 src\store\modules\window.store.js


/** @type {Module} */
const self = {
  // ...
  mutations: {
		/** 新增视窗 */
    add(state, component) {
      /** @type {Window} */
      const window = {
        component,
        key: getRandomString(),
        focusAt: dayjs().valueOf(),
      }

      state.list.push(window);
    },
    /** 删除视窗 */
    remove(state, id) {
      /** @type {Window[]} */
      const windows = state.list;

      const targetIndex = windows.findIndex((window) =>
        window.id === id
      );
      if (targetIndex < 0) {
        console.error(`[ window.store remove ] window 不存在,id : `, id);
        return;
      }

      windows.splice(targetIndex, 1);
    },
  },
  // ...
};

export default self;

接着建立范例视窗 src\components\window-example.vue

<template lang="pug">
base-window(
  :pos='pos',
  body-class='c-col p-20px pt-20px',
  title='范例视窗'
)
</template>

<style lang="sass">
</style>

<script>
import BaseWindow from '@/components/base-window.vue';

export default {
  name: 'WindowExample',
  components: {
    'base-window': BaseWindow,
  },
  props: {
    pos: {
      type: Object,
      default() {
        return {
          x: 0,
          y: 0,
        };
      },
    },
  },
  data() {
    return {
      id: this.$vnode.key,
    };
  },
  computed: {},
  watch: {},
  created() {},
  mounted() {},
  methods: {},
};
</script>

最後我们回到 app.vue,新增以下功能。

  • 引入 window-example.vue 组件
  • 从 Vuex 取得目前所有视窗。
  • 增加 addWindow(),向 Vuex 提交新增视窗

src\app.vue <script>

/**
 * @typedef {import('@/script/modules/port-transceiver').default} PortTransceiver
 * @typedef {import('@/types/type').Window} Window
 */

// ...

import WindowExample from '@/components/window-example.vue';

export default {
  name: 'App',
	components: {
    'dialog-system-setting': DialogSystemSetting,
    'window-example': WindowExample,
  },
  // ...
	computed: {
    ...mapState({
      /** @type {PortTransceiver} */
      portTransceiver: (state) => state.core.transceiver,

      ver: (state) => state.board.info.ver,
      firmwareName: (state) => state.board.info.firmwareName,

			/** @type {Window[]} */
      windows: (state) => state.window.list,
    }),
  },
	// ...
  methods: {
    // ...
    addWindow(name) {
      this.$store.commit('window/add', name);
    },
  },
};

在 pug 中增加「新增视窗用的右键选单」并「显示目前所有视窗」。

src\app.vue <template lang="pug">

.screen
  .windows
    component(
      v-for='(window, i) in windows',
      :is='window.component',
      :key='window.id',
      :pos='{ x: (i + 1) * 30, y: (i + 1) * 30 }'
    )

  .info.font-orbitron(v-if='firmwareName')
    | {{ firmwareName }} - v{{ ver }}

  dialog-system-setting

  // 右键选单
  q-menu(context-menu, content-class='border-radius-s')
    q-list.min-w-260px
      q-item(@click='addWindow("window-example")', clickable, v-close-popup)
        q-item-section
          | 新增「范例视窗」

D09 - 新增范例视窗.gif

可以任意新增视窗了!

加入其他视窗效果

可以看到现在就算点击视窗,也不会改变视窗堆叠的顺序,这样没办法看到最先生成的视窗内容,来着手加入调整重叠顺序功能吧!

预期功能

  • 视窗自动调整堆叠顺序
  • 关闭视窗功能

自动调整堆叠顺序

我们先透过 provide / inject 将视窗的 id 注入至所有子组件中。

src\components\window-example.vue <script>

import BaseWindow from '@/components/base-window.vue';

export default {
  name: 'WindowExample',
  // ...
  data() {
    return {
      id: this.$vnode.key,
    };
  },
  provide() {
    return {
      id: this.id,
    };
  },
  // ...
};

src\components\base-window.vue <script>

export default {
  name: 'BaseWindow',
  // ..
  inject: ['id'],
  // ..
  created() {
    if (!this.id) {
      throw new Error(`父组件必须透过 provide / inject 提供 id 数值`);
    }

    this.offset.x = this.pos.x;
    this.offset.y = this.pos.y;
  },
  // ..
};

接着在 src\store\modules\window.store.js 新增 focus 相关功能。

/**
 * 管理视窗相关资料
 */

// ...

/** @type {Module} */
const self = {
  namespaced: true,
  state: () => ({
    /** @type {Window[]} */
    list: [],

    focusId: null,
  }),
  mutations: {
    // ...

    /** 设目前 Focus 视窗 */
    setFocus(state, id) {
      state.focusId = id;

      /** @type {Window[]} */
      const windows = state.list;

      const target = windows.find((window) =>
        window.id === id
      );
      if (!target) {
        return;
      }

      target.focusAt = dayjs().valueOf();
    },
  },
  // ...
};

export default self;

并於 base-window.vue 新增 focus() Method

src\components\base-window.vue <script>

export default {
  name: 'BaseWindow',
  // ...
  methods: {
    foucs() {
      this.$store.commit('window/setFocus', this.id);
    },

    handleClick() {
      this.foucs();
    },

		/** 处理拖动事件
     *
     * 使用 Quasar v-touch-pan 指令实现
     *
     * [参考资料](https://v1.quasar.dev/vue-directives/touch-pan)
     */
    handleMove({ isFirst, isFinal, delta }) {
      // 拖动时让视窗 focus
      if (isFirst) {
        this.foucs();
      }

      // ...
    },
    // ...
  },
};

试试看 Vuex 有没有储存目前 focus 视窗的 ID。

D09 - base-window setFocus() 功能.gif

成功!接下来就是最关键的一步,以 focusAt 为依据,计算每个视窗的 z-index 达成自动调整重叠效果。

src\store\modules\window.store.jsgetters 加入 zIndexMap

/**
 * 管理视窗相关资料
 */

/**
 * @typedef {import('vuex').Module} Module
 * 
 * @typedef {import('@/types/type').Window} Window
 */

import { cloneDeep } from 'lodash-es';
import dayjs from 'dayjs';

import { getRandomString } from '@/script/utils/utils';

/** @type {Module} */
const self = {
  namespaced: true,
  // ...
  getters: {
		/** Window 对应的 z-index
     * 
     * 视窗 ID 与 z-index 以 key-value 对应
     * @example
     * map['abcds']: 1
     * map['gr56w']: 2
     */
    zIndexMap: (state) => {
      /** @type {Window[]} */
      const windows = cloneDeep(state.list);

      windows.sort((a, b) => a.focusAt > b.focusAt ? 1 : -1);

			return windows.reduce((map, window, index) => {
        map[window.id] = index;
        return map;
      }, {});
    },
  },
};

export default self;

接着在 base-window.vue 中提取 getters zIndexMap,用来取得自身 z-index

src\components\base-window.vue <script>

export default {
  name: 'BaseWindow',
  // ...
  computed: {
    zIndex() {
      const zIndexMap = this.$store.getters['window/zIndexMap'];
      return zIndexMap?.[this.id] ?? 0;
    },

    style() {
      const xSum = this.offset.x;
      const ySum = this.offset.y;

      const style = {
        zIndex: this.zIndex,
        top: `${ySum}px`,
        left: `${xSum}px`,
      };

      return style;
    },
    // ...
  },
  // ...
};

尝试看看堆叠有没有变化。

D09 - 自动调整视窗堆叠顺序.gif

接着加点视窗 focus 样式,让效果看起来酷一点 (´,,•ω•,,)

src\components\base-window.vue <script>

export default {
  name: 'BaseWindow',
  // ...
  computed: {
    // ...
    classes() {
      const classes = [];

      if (this.isFoucs) {
        classes.push('focused');
      }

      if (this.status.isMoving) {
        classes.push('moving');
      }

      return classes;
    },

    isFoucs() {
      return this.$store.state.window.focusId === this.id;
    },
  },
  // ...
};

src\components\base-window.vue <style scoped lang="sass">

@import '@/styles/quasar.variables.sass'

.base-window
  position: fixed
  min-width: 200px
  min-height: 100px
  overflow: hidden
  transition-duration: 0.5s
  transform: translateZ(0px)
  transition-timing-function: cubic-bezier(0.83, 0, 0.17, 1)
  box-shadow: $unfocus-shadow
  border-radius: $border-radius-m
  background: rgba(white, 0.8)
  backdrop-filter: blur(4px)
  &.focused
    background: rgba(white, 0.98)
    transform: translateY(-2px)
    box-shadow: $focus-shadow
    .base-window-header-bar .base-window-title
      letter-spacing: 2px
      transition-delay: 0.2s
      transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1)

  // ...

并在 app.vue 加入去除 focus 的事件,让滑鼠点到桌面时,所有视窗会取消 focus。

src\app.vue <template lang="pug">

.screen(@click='handleClick')
  // ...

src\app.vue <script>

// ...

export default {
  name: 'App',
  // ...
  methods: {
    handleClick() {
      this.$store.commit('window/setFocus', null);
    },
		// ...
  },
};

D09 - 视窗 focus 效果.gif

关闭视窗

最後就是关闭视窗功能了,由於我们已经在 window.store.js 完成删除功能(remove())。

所以只要完成 base-window.vue 预留的 handleClose() 即可。

src\components\base-window.vue <script>

export default {
  name: 'BaseWindow',
  // ...
  methods: {
    // ...
    handleClose() {
      this.$store.commit('window/remove', this.id);
    },
		// ...
  },
};

一行完成!ヽ(●`∀´●)ノ

D09 - 关闭视窗.gif

好像太快了.. ( ・ิω・ิ),那就来帮视窗出现与消失加上动画吧!

新增集中动画样式的 sass 档案。

src\styles\animation.sass

.fade-right-enter-active, .fade-right-leave-active
  transition-duration: 0.4s
  pointer-events: none
.fade-right-enter, .fade-right-leave-to
  transform: translateX(5px) !important
  opacity: 0 !important

.fade-up-enter-active, .fade-up-leave-active
  transition-duration: 0.4s
  pointer-events: none
.fade-up-enter, .fade-up-leave-to
  transform: translateY(-5px) !important
  opacity: 0 !important

.opacity-enter-active, .opacity-leave-active
  transition-duration: 0.4s
  pointer-events: none
.opacity-enter, .opacity-leave-to
  opacity: 0 !important

.list-complete-enter-active, .list-complete-leave-active, .list-complete-move
  transition-duration: 0.4s
  pointer-events: none
.list-complete-enter, .list-complete-leave-to
  opacity: 0 !important
  transform: translateY(30px) !important
.list-complete-leave-active 
  position: absolute !important

@keyframes bounce
  40%
    transform: scale(1.2)
  60%
    transform: scale(0.9)
  80%
    transform: scale(1.05)
  100%
    transform: scale(1)

@keyframes jelly-bounce
  40%
    transform: scale(0.5, 1.5)
  60%
    transform: scale(1.3, 0.7)
  80%
    transform: scale(0.9, 1.1)
  100%
    transform: scale(1, 1)

并在 src\main.js 引入 animation.sass

import Vue from 'vue';
import App from './app.vue';
import router from './router/router';
import store from './store/store';
import './quasar';
import i18n from './i18n';

import '@/styles/global.sass';
import '@/styles/animation.sass';
import 'windi.css';

Vue.config.productionTip = false;

new Vue({
  router,
  store,
  i18n,
  render: (h) => h(App),
}).$mount('#app');

最後在 app.vue 中把原本的 .windows div 换成 transition-group

src\app.vue <template lang="pug">

.screen(@click='handleClick')
  transition-group.windows(name='fade-up', tag='div')
    component(
      v-for='(window, i) in windows',
      :is='window.component',
      :key='window.id',
      :pos='{ x: (i + 1) * 30, y: (i + 1) * 30 }'
    )

  // ...

D09 - 视窗渐入渐出动画.gif

完成了!✧*。٩(ˊᗜˋ*)و✧*。

大家可以自行加入更酷的渐入渐出动画喔

以上我们成功完成视窗的基本功能了,接下来终於要进入我们硬体整合的部分了!

(电子助教:我终於可以登场了吗... (›´ω`‹ ))

总结

  • 完成视窗基本功能
  • 新增、关闭视窗

以上程序码已同步至 GitLab,大家可以前往下载:

GitLab - D09


<<:  [Day-14] while回圈

>>:  Day10 | Dart 非同步 - async/awiat

Day 3.环境预备备(一)-VSCode的加入

先来谈谈IDE是什麽吧! IDE,Integrated Development Environmen...

创建App-完结

创建App-完结 30天的学习日记就这样慢慢结束啦! 从第一天的专案设计到现成的样子,过程学习、错误...

DAY27 深度学习-卷积神经网路-Yolo v2 (一)

今天的主题是Yolo v2,不过所找到的资料图片可能比较不好理解,有更好的再更新,Yolo v2就是...

ThinkPHP V5.1 新增控制器

还不会创建ThinkPHP V5.1专案的朋友们可以先去看看创建ThinkPHP V5.1专案。 何...

IOS Swift 还能更精简? Closure的其它用法你一定要知道!!

前言: 屁屁痛了一整晚昨天全程跪着打文章,都这样了你们该进来看一下了吧,顺带一提如果有对Swift其...