zui设计思路

509 阅读9分钟

层级划分

  • 内容层 z-index 100-200
  • 导航层 z-index 200-300
  • 蒙版层 z-index 300-400

页面蒙版

在开发中,还有一类比较常见,就是我们用的页面蒙版。在做一些操作时,为了避免干扰会用蒙版把没用的内容都遮住。根据固定横栏的经验,全屏覆盖想起来就很简单了,把height 也换成 100% 不就可以了。但是这里我们要注意,这种功能靠百分比做的全屏蒙版不太灵活。假如需要蒙版把标题栏的部分留出来,这时候靠百分比就不灵了。所以在做蒙版的时候通常使用下面这种方式:

<!-- 页面蒙版 -->
<div class="z-mask"></div>

/* 页面蒙版 */
.z-mask{
    position: fixed;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    background: rgba(0,0,0,.5);
    z-index:300
}

这其中给每个方向上定位的值都设置为 0,就会把蒙版撑开到全屏。这样做的好处是假如需要留出来标题栏,就可以调整 top 值来实现,需要留下面导航栏就调整 bottom 值来实现,非常方便。

内容区

/* 内容区 */
.z-content{
    height: 100%;
    box-sizing: border-box;
    overflow-y: auto;
}
/* 根据header和navbar自动适应内容区高度 */
.z-header ~ .z-content{
    padding-top: 45px;
}
.z-navbar ~ .z-content{
    padding-bottom: 50px;
}

移动端1px实现

使用svg画线,svg的1px是真的对应1px

  • postcss-write-svg 插件处理移动端1px
npm i postcss-write-svg -S

.postcssrc.js

module.exports = {
    "plugins": {
             "postcss-write-svg": {
                 utf8: false
              },
    }
}
@svg border-1px{
    height:4px;
    width:4px;
    @rect{
        fill:transparent;
        width:100%;
        height:100%;
        stroke-width: 25%;  // 边框宽度 4px * 25%(即1px)
        stroke: var(--color, black);  // 颜色
    }
}
.test{
    margin:100px;
    width:100px;
    height:100px;
    border:1px solid;
    border-image:@svg(border-1px param(--color:#f50)) 1 stretch;
}

组件设计

button组件

button功能

  • 类型(默认样式 ,自定义样式)
  • 尺寸 (大,默认,小)
  • 图标 (左,右)
  • 加载状态
  • 块级元素
  • 禁用

button样式的全局变量

//button component
//type:default
@button-height:32px;
@button-bg:#fff;
@button-color:#333;
@button-font-size:14px;
@button-border-color:#999;
@button-active-bg:#eeeeee;
//type:custom
@button-custom-bg:linear-gradient(66deg,rgba(232,48,56,1),rgba(247,88,151,1));
@button-custom-color:#fff;
@button-custom-font-size:14px;
@button-custom-active-bg:rgba(247,88,151,1);
//size:small
@button-mini-height: 22px;
//size:large
@button-large-height: 36px;

参数

    //块级元素
    block: {
      type: Boolean,
      default: () => {
        return false
      }
    },
    //类型
    type: {
      type: String,
      default: () => {
        return 'default'
      },
      validator: value => value == 'default' || value == 'custom'
    },
    //尺寸
    size: {
      type: String,
      default: () => {
        return 'common'
      },
      validator: value =>
        value == 'small' || value == 'large' || value == 'common'
    },
    //是否下载状态
    isLoading: {
      type: Boolean,
      default: () => {
        return false
      }
    },
    //icon
    iconName: {
      type: String,
      default: () => {
        return ''
      }
    },
    //icon 位置
    iconPosition: {
      type: String,
      default: () => {
        return 'left'
      },
      validator: value => {
        return value == 'left' || value == 'right'
      }
    }

IOS上不支持active伪类

解决:

 mounted() {
    document.body.addEventListener('touchstart', function() {})
 }

做移动端开发的时候遇到ios点击效果会自带背景阴影,去掉阴影的方法:

*{   
    -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
    -webkit-tap-highlight-color:transparent;
}

-webkit的意思是:IOS浏览器;

-tap的意思是:点击;

-highlight的意思是:背景高亮;

-color的意思是:颜色

rgba(0,0,0,0) 和 transparent这两个是一个意思,就是纯黑透明;

icon组件

使用iconfont 矢量图

  • 小红点
  • 角标
  • 颜色填充 参数
iconName: {
      //icon 名称
      type: String,
      default: () => {
        return ''
      }
    },
    dot: {
      //右上角的小红点
      type: Boolean,
      default: () => {
        return false
      }
    },
    info: {
      //角标
      type: [Number, String],
      default: () => {
        return 0
      }
    }
    color: {
      //颜色
      type: String,
      default: () => {
        return ''
      }
    }

角标样式

  .z-badge {
    position: absolute;
    top: 0;
    right: 0;
    box-sizing: border-box;
    min-width: 16px;
    padding: 0 3px;
    color: #fff;
    font-weight: 500;
    font-size: 12px;
    line-height: 14px;
    text-align: center;
    background-color: @z-badge-bg;
    border: 1px solid #fff;
    border-radius: 16px;
    transform: translate(50%, -50%);
    transform-origin: 100%;
  }

svg设置高度后 父级div会比svg高度高4px左右

原因

SVG主要有3种使用场景

1、作为背景图片使用

2、src引入,作为图片使用

3、直接在标签中使用(内联SVG)

在第3中使用方式中,SVG元素是一个inline类型的标签,浏览器会为标签之间的换行和空格,生成一个看不见的空文本节点,这个空的文本节点占据了位置,增加了一个看不见的高度。

解决方式

1、负边距消除 (margin-bottom: -4px;)

2、改成块级标签(display: block; float等)

cell单元格组件

cell 全局变量

@cell-padding-left-right:12px;
@cell-padding-top-bottom:12px;

左右两边各放置插槽

属性

 title:{ //单元格内容
          type:String,
          default:()=>{
              return ""
          },
          required:true
      },
      path:{ //路由跳转路径
        type:Object,
        default:()=>{
          return null
        }
      }

z-input 输入框组件

  • 提示
  • v-model
  • type
  • disabled 禁用
  • 错误提示
  • 直接在组件外部监听组件内input的所有事件
  • 清除icon 属性
    placeholder:{//提示
      type:String,
      default:()=>{
        return "请输入内容"
      }
    },
    value:{ //输入值
      type:[String,Number],
      default:()=>{
        return ""
      },
      required:true
    },
    type:{ //类型
      type:String,
      default:()=>{
        return ""
      }
    },
    disabled:{ //禁用状态
      type:Boolean,
      default:()=>{
        return false
      }
    },
    errorTip:{ //错误提示
      type:String,
      default:()=>{
        return ""
      }
    }

z-sticky吸顶组件

  • 吸顶
  • 设置吸顶距离 属性
  props: {
    offsetTop: { //吸顶时与顶部的距离
      type: Number,
      default: () => {
        return 0
      }
    }
  },

获取最近的滚动的父级容器(来源vant)

    getScroll(el, root = window) {
      const overflowScrollReg = /scroll|auto/i
      let node = el
      while (
        node &&
        node.tagName !== 'HTML' &&
        node.nodeType === 1 &&
        node !== root
      ) {
        const { overflowY } = window.getComputedStyle(node)
        if (overflowScrollReg.test(overflowY)) {
          if (node.tagName !== 'BODY') {
            return node
          }
          const { overflowY: htmlOverflowY } = window.getComputedStyle(
            node.parentNode
          )
          if (overflowScrollReg.test(htmlOverflowY)) {
            return node
          }
        }
        node = node.parentNode
      }
      return root
    }

判断是否吸顶

滚动距离>=元素距离顶部的距离 - 吸顶时与顶部的距离
 //监听滚动事件并设置吸顶
    setScrollListener() {
      let scrollRoot = this.getScroll(this.$el)
      let eleToTop = this.$el.getBoundingClientRect().top
      scrollRoot.addEventListener('scroll', () => {
        if (scrollRoot.scrollTop >= eleToTop - this.offsetTop) {
          this.isSticky = true
        } else {
          this.isSticky = false
        }
        //放在下面,不然会出现抖动(吸顶已经浮动,但是添加高度还未0)
        this.$emit('scroll', {
          scrollTop: scrollRoot.scrollTop,
          isSticky: this.isSticky
        })
      })
    },

Tab标签页

  • v-model
  • tab标签超过4个,标签栏可以在水平方向上滚动,切换时会自动将当前标签居中
  • 自定义标签
  • 吸顶sticky
 props: {
    isSticky: {
      //是否吸顶
      type: Boolean,
      default: () => {
        return false
      }
    },
    active: {
      //激活索引
      type: Number,
      default: () => {
        return 0
      },
      required: true
    },
    offsetTop: {
      //吸顶距离
      type: Number,
      default: () => {
        return 0
      }
    }
  },

v-model实现思路

示例

  <z-tabs v-model="active">
        <z-tab title="tab-1">
          <div class="tab-content">内容一</div>
        </z-tab>
        <z-tab title="tab-2">
          <div class="tab-content">内容二</div>
        </z-tab>
        <z-tab title="tab-3">
          <div class="tab-content">内容三</div>
        </z-tab>
 </z-tabs>

配置事件

model: {
    prop: 'active',
    event: 'change'
},

获取子组件并在每个子组件上监听点击事件,在回调中this.$emit('change', index)发送change事件

//获取子元素列表
    let chidren = [];
    if (this.isSticky) {//有吸顶的情况
      chidren = this.$children[0].$children;
      //获取插槽个数
      this.length = this.$children[0].$children.length;
    } else {
      chidren = this.$children;
      //获取插槽个数
      this.length = this.$children.length;
    }
    
     //设置v-model
    setVModel(chidren) {
      chidren.forEach((element, index) => {
        element.$el.onclick = () => {
          this.$emit("change", index);
          this.$emit("click", { index, title: element.title });
          this.reset(chidren);
          element.isActive = true;
          this.scroll(element);
        };
        if (index == this.active) {
          element.isActive = true;
        }
        this.setTabContent(index, element);
      });
    },

设置标签页的内容 (外部使用时是在z-tab组件中插入的,需要把内容重新插入到z-tab组件下面的div中)

遇到的问题:如果插入的时内部使用路由(this.$router)的组件,则会报错提示路由underfined

  //创建并添加tabContent组件
    setTabContent(index, element) {
      let that = this;
      let tabContentWrap = this.$el.querySelector(".tab-content-wrap");
      let ZTabContent = {
        props: {
          index: {
            type: Number,
            default: () => {
              return 0;
            },
            required: true
          }
        },
        render() {
          return (
            <div style={{ display: this.index == that.active ? "" : "none" }}>
              {element.$slots.default}
            </div>
          );
        }
      };
      let ZTabContentConstructor = Vue.extend(ZTabContent);
      let component = new ZTabContentConstructor({
        propsData: {
          index: index
        }
      });
      let div = document.createElement("div");
      tabContentWrap.appendChild(div);
      component.$mount(div);
    },

切换时会自动将当前标签居中

滚动距离=点击元素距离屏幕左边的大小-屏幕大小的一半+点击元素宽度的一半大小 通过改变滚动条距离左边的距离来滚动,并设置最大值为(tabs.scrollWidth - window_width),最小值为0

 //滚动
    scroll(element) {
      let el = element.$el;
      let window_width =
        document.documentElement.clientWidth || document.body.clientWidth;
      let tabs = this.$el.querySelector(".z-tabs");
      let offsetLeft = el.offsetLeft - tabs.scrollLeft;
      let half_width = el.offsetWidth / 2;
      let half_window_width = window_width / 2;
      let scrollL = tabs.scrollLeft;
      let totalScrollDistance =
        scrollL + (offsetLeft - half_window_width) + half_width;
      if (totalScrollDistance > tabs.scrollWidth - window_width) {
        totalScrollDistance = tabs.scrollWidth - window_width;
      } else if (totalScrollDistance < 0) {
        totalScrollDistance = 0;
      }
      let to_scrollLeft = offsetLeft - half_window_width + half_width;
      let params = {
        scrollDuration: 1500,
        tabs,
        to_scrollLeft,
        totalScrollDistance,
        direction: to_scrollLeft > 0 ? "right" : "left"
      };
      this.animate(params);
    },

滚动动画

  • requestAnimationFrame

水水地写下(快速点击会有点滚动问题)

 animate({
      scrollDuration,
      tabs,
      to_scrollLeft,
      totalScrollDistance,
      direction
    }) {
      let scrollCount = (scrollDuration / 1000) * 15
      let stepDistance = to_scrollLeft / scrollCount
      function step(newTimestamp) {
        if (direction == 'right') {
          if (tabs.scrollLeft >= totalScrollDistance) {
            tabs.scrollLeft = totalScrollDistance
            return
          }
        } else {
          if (tabs.scrollLeft <= totalScrollDistance) {
            tabs.scrollLeft = totalScrollDistance
            return
          }
        }
        tabs.scrollLeft = (tabs.scrollLeft * 100 + stepDistance * 100) / 100
        window.requestAnimationFrame(step)
      }
      window.requestAnimationFrame(step)
    },

头部导航栏组件的开发

<template>
    <header class="z-header border-bottom-1px">
        <div class="left">
            <slot name="left"></slot>
        </div>
        <div class="center flex justify-center aligin-center">
            <div class="title  ellipsis " v-if="!customCenter">{{title}}</div>
            <template v-else>
                <slot name="center"></slot>
            </template>
        </div>
        <div class="right">
            <slot name="right"></slot>
        </div>
    </header>
</template>

<script>
export default {
  components: {},
  props: {
    title: {
      type: String,
      default: () => {
        return "标题";
      }
    },
    //   自定义中心内容
    customCenter: {
      type: Boolean,
      default: () => {
        return false;
      }
    }
  },
  data() {
    return {};
  },
  computed: {},
  watch: {},
  methods: {},
  created() {},
  mounted() {},
  updated() {}, //生命周期 - 更新之后
  destroyed() {} //生命周期 - 销毁完成
};
</script>
<style lang='less' scoped>
.z-header {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 45px;
  z-index: 200;
  background: #fff;
  box-sizing: border-box;
  .left {
    position: absolute;
    left: 15px;
    top: 0;
    height: 100%;
    z-index: 101;
  }
  .center {
    width: 100%;
    height: 100%;
    .title{
        height: 100%;
        line-height: 45px;
        max-width: 150px;
    }
  }
  .right {
    position: absolute;
    right: 15px;
    top: 0;
    height: 100%;
    z-index: 101;
  }
}
</style>


底部导航栏


<template>
    <div class="tt-navbar flex">
        <template v-for="(item,index) in bottomMenu">
            <router-link tag="div" :to="item.path" class="menu-item flex justify-center aligin-center flex-column" :key="index">
                <slot :item="item" :active="$route.path.match(item.path)" ></slot>
                <div class="mt-6">{{item.name}}</div>
            </router-link>
        </template>
    </div>
</template>

<script>
export default {
  components: {},
  props: {
    bottomMenu: {
      type: Array,
      default: () => {
        return [];
      }
    }
  },
  data() {
    return {};
  },
  computed: {},
  watch: {},
  methods: {},
  created() {},
  mounted() {},
  updated() {}, //生命周期 - 更新之后
  destroyed() {} //生命周期 - 销毁完成
};
</script>
<style lang='less' scoped>
//@import url(); 引入公共css类
/* 底部导航栏 */
.tt-navbar {
  position: fixed;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 50px;
  background: #fff;
  box-sizing: border-box;
  border-top: 1px solid #ddd;
  z-index: 200;
  .menu-item {
    flex: 1;
    height: 100%;
  }
  .mt-6 {
    margin-top: 6px;
  }
}
</style>

使用

 <BottomNav :bottomMenu="bottomMenu">
            <template v-slot="{item,active}">
                <img class="img" :src="active?item.imgSelected:img" alt="">
            </template>
 </BottomNav>

按钮

.zz-button{
    transition: all 0.3 ease;
}
.zz-button:active{
    opacity: 0.5;
    transform: scale(0.95,0.95);
}

在ios 会出现active失效的问题 解决:

document.body.addEventListener('touchstart',function(){});

开关

.switch {
  position: relative;
  box-sizing: content-box;
  width: 45px;
  height: 20px;
  border: 1px solid #ccc;
  outline: 0;
  border-radius: 15px;
  transition: all .3s ease;
  background-color: rgba(0, 0, 0, 0.1);
  /* 去掉webkit内核里默认的样式 */
  -webkit-appearance: none;
  /* 去掉webkit内核里默认的点击效果 */
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
  &:checked {
    border-color: #07c160;
    background-color: #07c160;
  }
  &::after {
    content: " ";
    position: absolute;
    top: 0;
    left: 0;
    width: 20px;
    height: 20px;
    transition: left .3s ease;
    border-radius: 50%;
    background-color: #ffffff;
    box-shadow: 0 0 2px #999;
  }
}
.switch:checked.switch::after{
    left:25px ;
}

dialog

.content {
  z-index: 301;
  width: 245px;
  height: 400px;
  border-radius: 5px;
  background: #fff;
  animation: show 0.3s 1;
  box-shadow: 14px 25px 16px 7px rgba(0, 0, 0, 0.05);
  @keyframes show {
    0% {
      opacity: 0;
      transform: scale(1.5);
    }
    100% {
      opacity: 1;
      transform: scale(1);
    }
  }
}

列表左滑组件

<!--  -->
<template>
    <div class="delete">
        <div class="slider">
            <div
                class="content"
                @touchstart="touchStart"
                @touchmove="touchMove"
                @touchend="touchEnd"
                :style="deleteSlider"
            >
                <!-- 插槽中放具体项目中需要内容         -->
                <slot></slot>
            </div>
            <div class="remove" ref="remove">删除</div>
        </div>
    </div>
</template>

<script>
export default {
  components: {},
  data() {
    return {
      startX: 0, //触摸位置
      endX: 0, //结束位置
      moveX: 0, //滑动时的位置
      disX: 0, //移动距离
      deleteSlider: "" //滑动时的效果,使用v-bind:style="deleteSlider"
    };
  },
  computed: {},
  watch: {},
  methods: {
    touchStart(ev) {
      ev = ev || event;
      //tounches类数组,等于1时表示此时有只有一只手指在触摸屏幕
      if (ev.touches.length == 1) {
        // 记录开始位置
        //clientX 触点相对于可见视区(visual viewport)左边沿的的X坐标. 不包括任何滚动偏移. 只读属性.
        this.startX = ev.touches[0].clientX;
      }
    },
    touchMove(ev) {
      ev = ev || event;
      //获取删除按钮的宽度,此宽度为滑块左滑的最大距离
      let wd = this.$refs.remove.offsetWidth;
      if (ev.touches.length == 1) {
        // 滑动时距离浏览器左侧实时距离
        this.moveX = ev.touches[0].clientX;
        //起始位置减去 实时的滑动的距离,得到手指实时偏移距离
        this.disX = this.startX - this.moveX;
        // 如果是向右滑动或者不滑动,不改变滑块的位置
        if (this.disX < 0 || this.disX == 0) {
          this.deleteSlider = "transform:translateX(0px)";
          // 大于0,表示左滑了,此时滑块开始滑动
        } else if (this.disX > 0) {
          //具体滑动距离我取的是 手指偏移距离*5。
          this.deleteSlider = "transform:translateX(-" + this.disX * 5 + "px)";

          // 最大也只能等于删除按钮宽度
          if (this.disX * 5 >= wd) {
            this.deleteSlider = "transform:translateX(-" + wd + "px)";
          }
        }
      }
    },
    touchEnd(ev) {
      ev = ev || event;
      let wd = this.$refs.remove.offsetWidth;
      if (ev.changedTouches.length == 1) {
        /*changedTouches只读属性是TouchList其接触点(Touch对象)根据事件类型而变化,如下所示:
             对于该touchstart事件,它是对当前事件变为活动的触摸点列表。
             对于该touchmove事件,它是自上次事件以来已更改的触摸点列表。
             对于该touchend事件,它是已从表面移除的触摸点的列表(即,与手指不再接触表面的触摸点集合)。*/
        let endX = ev.changedTouches[0].clientX;
        this.disX = this.startX - endX;
        //如果距离小于删除按钮一半,强行回到起点
        if (this.disX * 5 < wd / 2) {
          this.deleteSlider = "transform:translateX(0px)";
        } else {
          //大于一半 滑动到最大值
          this.deleteSlider = "transform:translateX(-" + wd + "px)";
        }
      }
    }
  },
  created() {},
  mounted() {},
  updated() {}, //生命周期 - 更新之后
  destroyed() {} //生命周期 - 销毁完成
};
</script>
<style scoped lang="less">
.slider {
  width: 100%;
  height: 100px;
  position: relative;
  user-select: none;
  .content {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background: green;
    z-index: 100;
    //    设置过渡动画
    transition: 0.3s;
  }
  .remove {
    position: absolute;
    width: 200px;
    height: 100px;
    background: red;
    right: 0;
    top: 0;
    color: #fff;
    text-align: center;
    font-size: 20px;
    line-height: 100px;
  }
}
</style>
  • TouchEvent.touches属性返回一个TouchList实例,成员是所有仍然处于活动状态(即触摸中)的触摸点。一般来说,一个手指就是一个触摸点。

ActionSheet

/* 弹出菜单容器,默认隐藏在屏幕的下面 */
.tt-action-sheet > .tt-action-sheet-wrap{
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    max-width: 640px;
    margin: auto;
    background: #eee;
    transition: transform .3s ease;
    transform: translateY(100%);
    z-index: 301;
}
/* 菜单弹出的时候,改变容器位移 */
.tt-action-sheet.show .tt-action-sheet-wrap{
    transform: translateY(0);
}

弹出菜单这个组件通常是藏在页面下方,不会用的时候再加载,所以要让它一直存在于 DOM 中。这样就会造成一个问题,后面的蒙版层我们可以用透明度 opacity 属性来实现淡入淡出效果。当淡出以后蒙版的透明度是 0,但这个元素还是遮盖着后面的内容区的,导致内容区的操作不能进行。遇到这种情况,就要介绍一下“pointer-events”这个属性了。

auto

  • pointer-events 属性默认的取值就是 auto,使用这个属性值的情况下,HTML 元素就是正常的触发点击事件,通常只有为了覆盖不同取值的时候才会使用这个值。

none

  • 给元素用上这个属性值的话,这个元素就变成点不中的了,无论这个元素是什么样式,点击事件都会忽略它而去触发它底层元素的点击事件。
/* 默认隐藏蒙版 */
.tt-action-sheet > .tt-mask{
    opacity: 0;
    /* 屏蔽元素的点击事件 */
    pointer-events: none;
    transition: opacity .3s ease;
}
/* 菜单弹出的时候显示蒙版 */
.tt-action-sheet.show > .tt-mask{
    opacity: 1;
    pointer-events: auto;
}

img 去除黑边

img[src=""],img:not([src]){
    opacity: 0;
}