啥?掘金APP下拉菜单组件被我"抄"了!

155 阅读2分钟

这次准备分享一个下拉菜单组件,下拉菜单组件现在更多是电商类的搜索商品使用的更多一些,我记得掘金app搜索页也使用到了这个效果,主要分享一下组件编写思路以及前期踩坑。这整个组件实际是由两个组件组成的,其中一个是抽屉组件。另外一个就是今天主角下拉菜单组件,我们废话不多说,直接上效果以及和看看掘金app效果。

本人实现效果

menu.gif

掘金app效果

juejin_menu.gif

当我们要去编写一个组件的时候,我们最好去看看别的组件库官网关于这个组件库的props以及暴露的事件,以及学习props和暴露事件的命名,比如组件库有许多,我这里还是以vant组件库为例,基本将大致的功能进行实现。

踩坑全步骤

编写组件布局方面一定要考虑全面,本人一开始将所有内容放在子组件里面,结果去点击title去打开相应弹窗的打开的都是最后一个弹窗,就比如我点击全部商品显示的是好评排序内容弹窗。后面更换布局将title显示父组件里面也就是我们vuxDropDownMenu组件。在该组件使用this.$children去获取title,主要代码如下 然后给它绑定点击事件,点击的时候去获取相应索引,根据索引打开对应弹窗,以及关闭之前打开的弹窗,只保留当前一个弹窗。

image (1).png

image.png

点击title打开弹窗,给弹窗内容绑定相应点击事件,点击我们选择好的内容之后我们就要去更新我们的titleList也就是要更新我们的randerTitle()方法。

image (2).png

选择内容之后我们要做两件事,关闭当前弹窗,然后抛出事件,以及去监听value,只要value改变了我就去执行randerTitle()方法即可。

image (5).png

里面使用到部分iocn,本人这里因为当前环境没下载iconfont图标,只是用文字进行简单代替,如果其他掘友想使用本组件直接替换相应icon即可,然后该组件还有许多功能可以进行添加,比如弹窗内容一行多列,比如加入底部按钮插槽等,以及该组件代码有许多优化的点还可以进行优化。大家使用之后或者看完相应代码之后有觉得不好的地方请踊跃指出!

我们再次废话不多说,直接上源码

vuxDropDownMenu组件代码

<template>
  <div ref="Rect" class="vux-down-menu">
    <div v-for="(item,key) in titleList" :key="key" class="vux-drop-down-menu title"
         @click="handleClickTitle(key)">
      <span :class="[disableStyle(item,key)]" :style="{color:activeColorStyle(item,key)?activeColor:''}"
            style="font-weight: 500">{{ item.text }}</span>
      <span :class="[disableStyle(item,key),activeColorStyle(item,key)?'down':'up']"
            :style="{color:activeColorStyle(item,key)?activeColor:'' }" class="icon">^</span>
    </div>
    <slot></slot>
  </div>
</template>
<script>
import {useRect} from "../uitls";

![image (5).png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/854c0278480940cb821541f67ed70ecc~tplv-k3u1fbpfcp-watermark.image?)
export default {
  name: "vuxDropDownMenu",
  data() {
    return {
      titleList: [],
      offset: 0
    }
  },
//   参数  说明 类型 默认值
//   active-color  菜单标题和选项的选中态颜色  string #ee0a24
//   direction 菜单展开方向,可选值为up  string down

  props: {
    activeColor: {
      type: String,
      default: '#0068FF'
    },
    direction: {
      type: String,
      default: 'down',
      validate(val) {
        return ['up', 'down'].includes(val)
      }
    },
    closeOnClickOverlay: {
      type: Boolean, default: true
    }
  },

  activated() {
    this.updateOffset()
    this.renderTitle()
  },
  mounted() {
    const _this = this;
    this.updateOffset()
    this.renderTitle()
    window.addEventListener("resize", this.updateOffset);
    window.addEventListener("click", function (evt) {
      const rect = _this.$refs.Rect;
      if (!rect.contains(evt.target)) {
        _this.$children.forEach(item => {
          item.toggle(false)
        })

      }


    });

  },
  methods: {

    activeColorStyle(item, index) {
      //打开之后title高亮
      if (this.$children[index].isOpen) {
        return item.value == this.$children[index].value;
      }
    },
    disableStyle(item, index) {
      if (this.$children[index].disabled) {
        return 'disabled'
      }

    },
    //获取每个value的值显示的title
    renderTitle() {
      const value = this.$children.map(item => item.value)
      const data = this.$children.map(item => item.options)
      this.titleList = data.map((it, i) => {
        return it.find(item => item.value == value[i]);
      });

    },

    updateOffset() {
      //获取边距
      const rect = useRect(this.$refs.Rect);
      const {top, bottom} = rect;
      if (this.direction === 'down') {
        this.offset = bottom;
      } else {
        this.offset = window.innerHeight - top;
      }

    },

    handleClickTitle(active) {

      if (this.$children[active].disabled) {
        return
      }
      this.$children.forEach((item, index) => {
        if (active === index) {
          this.updateOffset()
          //调用当前实例方法
          item.toggle()
          //其他都关闭
        } else if (item.isOpen) {
          item.toggle(false)
        }
      })
    }

  }


}
</script>

<style lang="less" scoped>
.vux-down-menu {
  display: flex;
  align-items: center;
  justify-content: space-around;
  width: 100%;
  flex: 1;
  box-shadow: 0 2px 12px rgba(100, 101, 102, .12);


}

.disabled {
  opacity: 0.6 !important;
  cursor: not-allowed !important;
}

.icon {
  &.up {
    transition: all 0.25s linear;
    transform: rotate(180deg);
  }

  &.down {
    transition: all 0.25s linear;
  }
}

.vux-drop-down-menu {
  &.title {
    background-color: #ffffff;
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    height: 40px;
    line-height: 40px;
    font-size: 14px;
    color: #333;
    font-weight: 500;
    min-width: 0;
    z-index: 9999;

  }

}

</style>

useRect.js

const isWindow = (val) => {
    return val === window;
}
const makeDOMRect = (width, height) =>

    ({
        top: 0, left: 0, right: width, bottom: height, width, height,
    });


export const useRect = (element) => {

    if (isWindow(element)) {
        const width = element.innerWidth;
        const height = element.innerHeight;
        return makeDOMRect(width, height);
    }

    if (element.getBoundingClientRect) {
        return element.getBoundingClientRect();
    }
    return makeDOMRect(0, 0);
};

vuxDropDownMenuItem组件代码

<template>
  <div v-if="showWarp" :style="[offsetStyle,heightStyle]" style="position: absolute;width: 100%;transition: height 10s">
    <vux-drawer v-model="isOpen" :close-on-click-overlay="closeOnClickOverlay" :placement="directionStyle"
                style="position: absolute;overflow-y: auto;"
                @click.native="clickCloseMark">
      <div v-for="(item,key) in options" :key="key" class="vux-down-menu-item" @click="handleClickItem(item)">
        <div style="display: flex;justify-content: space-between;">
          <div :style="{color:activeColorStyle(item)?activeColor:''}">{{ item.text }}</div>
          <div v-if="activeColorStyle(item)">
            <span :style="{color:activeColorStyle(item)?activeColor:''}"></span></div>
        </div>
      </div>
      <slot></slot>
    </vux-drawer>

  </div>

</template>

<script>

export default {
  name: "vuxDropDownMenuItem",

  data() {
    return {
      isOpen: false,
      showWarp: false,
      offset: 0,
      activeColor: this.$parent.activeColor,
      direction: this.$parent.direction,
      height: 0,
      closeOnClickOverlay: this.$parent.closeOnClickOverlay,

    }
  },
  model: {
    prop: 'value',
  },
  props: {

//       参数  说明 类型 默认值
//       v-model 当前选中项对应的 value number | string  -
//     title 菜单项标题  string 当前选中项文字
//   options 选项数组 Option[] []
//   disabled  是否禁用菜单 boolean  false
    value: {type: [String, Number, Array, Boolean]},
    disabled: {
      type: Boolean,


    },
    title: {
      type: [Number, String]
    },
    options: {
      type: Array,
      default: () => []
    }


  },
  computed: {
    directionStyle() {
      return this.direction === 'down' ? 'top' : 'bottom';
    },
    offsetStyle() {
      if (this.direction === 'down') {
        return {
          top: this.$parent.offset + 'px',
        }
      } else {
        return {
          bottom: this.$parent.offset + 'px',
        }
      }

    },
    heightStyle() {
      if (this.direction === 'down') {
        return {
          height: this.height - this.$parent.offset + 'px',
        }
      } else {
        return {
          height: this.height + 'px',
        }
      }
    }
  },

  watch: {
    value(newVal, oldVal) {
      if (newVal !== oldVal) {
        this.$emit('input', newVal)
        this.$parent.renderTitle();
      }
    }

  },
  activated() {
    this.init()
  },
  mounted() {
    this.init()
  },
  methods: {
    init() {
      this.height = window.innerHeight;
    },
    handleClickItem(option) {
      if (this.disabled) {
        return
      }
      this.isOpen = false;
      this.showWarp = false;
      this.$emit('input', option.value);
      this.$emit('change', option);
    },
    toggle(show = !this.isOpen) {
      if (show === this.isOpen) {
        return;
      }
      this.isOpen = show;
      this.showWarp = show;

    },
    clickCloseMark() {
      // if (!this.closeOnClickOverlay) {
      //   return
      // }

      this.$children.forEach(item => {
        item.isOpen = false;
        item.showWarp = false;
      })
    },
    activeColorStyle(item) {
      return item == this.options.find(item => item.value === this.value);
    },


  }
}
</script>

<style lang="less" scoped>


.vux-down-menu-item {
  height: 40px;
  line-height: 40px;
  text-align: left;
  box-sizing: border-box;
  padding: 0 20px;
  border-bottom: 1px solid #eee;
}


</style>

vuxDrawer(抽屉、弹层)组件代码

<template>
  <div :class="caleClass" @click="closeMaskHandle">
    <div :class="[round?'round':'',curOpen?'prevent-touch-move':'']" :style="{height:heightByStyle,width:widthStyle}"
         class="drawer-content">
        <span v-if="closable" class="close-icon"
              @click="handleClose"
        ><i class="iconfont icon-close"></i></span>
      <slot>
      </slot>
    </div>
  </div>
</template>

<script>
export default {
  name: "VuxDrawer",
  data() {
    return {
      curOpen: this.open,
    };
  },
  model: {
    prop: 'open',
    event: 'input'
  },
  props: {

    round: {
      type: Boolean,
      default: false,
    },
    closable: {
      type: Boolean,
    },
    height: {
      type: [String, Number],

    },
    width: {
      type: [String, Number],
    },
    open: {
      type: Boolean,
      default: false,
    },
    placement: {
      type: String,
      default: 'bottom',
      validator(val) {
        return ['bottom', 'left', 'right', 'top'].includes(val);
      },
    },
    //是否显示
    showMark: {
      type: Boolean,
      default: true
    },
    closeOnClickOverlay: {
      type: Boolean,
      default: true
    }
  },
  computed: {

    caleClass() {
      return [
        this.showMark ? 'drawer-mask' : '',
        this.placement,
        {
          'is-open': this.curOpen,
          'is-close': !this.curOpen
        }
      ]
    },

    heightByStyle() {
      if (['top', 'bottom'].includes(this.placement)) {
        if (parseInt(this.height) <= 100) {
          return this.height + '%'
        } else {
          return 'auto'
        }
      }

    },
    widthStyle() {
      if (['left', 'right'].includes(this.placement)) {
        if (parseInt(this.width) <= 100) {
          return this.width + '%'
        } else {
          return 'auto'
        }
      }

    },
  },

  watch: {
    open(val) {
      this.curOpen = val;
    },
    curOpen(val) {
      this.$emit('input', val)
    }
  },

  methods: {
    handleClose() {

      //事件点击无效又可能是层级导致的
      this.curOpen = false;
    },

    closeMaskHandle(e) {
      if (e.target !== this.$el) {
        return;
      }
      if (!this.closeOnClickOverlay) {
        return;
      }
      this.handleClose();
    },


  },


};
</script>
<style lang="scss" scoped>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* 防止出现滚动 穿透*/
//.prevent-touch-move {
//  position: fixed;
//  overflow: hidden;
//  width: 100%;
//  height: 100%;
//  top: 0;
//  left: 0;
//}

.drawer-mask {
  //overflow: hidden;
  z-index: 9998 !important;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  transition: opacity 300ms, height 2ms;


  .drawer-content {
    position: absolute;
    height: 80%;
    background-color: #fff;
    transition: transform 300ms;


    .close-icon {
      z-index: 9999;
      margin-top: 6px;
      font-size: 16px;
      position: absolute;
      right: 20px;
    }
  }

  &.is-open {
    opacity: 1;
    height: 100%;
  }

  &.is-close {
    opacity: 0;
    height: 0;
    transition-delay: 300ms;
  }

  &.bottom {
    .drawer-content {
      top: auto;
      left: 0;
      right: 0;
      bottom: 0;


      &.round {
        border-top-left-radius: 10px;
        border-top-right-radius: 10px;
      }

    }

    &.is-open {


      .drawer-content {
        transform: translateY(0);
      }
    }

    &.is-close {


      .drawer-content {
        transform: translateY(100%);
      }
    }
  }

  &.top {
    z-index: -1;

    .drawer-content {
      top: 0;
      left: 0;
      right: 0;
      bottom: auto;

      &.round {
        border-bottom-left-radius: 10px;
        border-bottom-right-radius: 10px;
      }

    }

    &.is-open {

      .drawer-content {
        transform: translateY(0);
      }
    }

    &.is-close {
      .drawer-content {
        transform: translateY(-100%);
      }
    }
  }

  &.left {
    .drawer-content {
      height: 100% !important;
      width: 80%;
      top: 0;
      left: 0;
      right: auto;
      bottom: 0;

      &.round {
        border-top-right-radius: 10px;
        border-bottom-right-radius: 10px;
      }

    }

    &.is-open {

      .drawer-content {
        transform: translateX(0);
      }
    }

    &.is-close {

      .drawer-content {
        transform: translateX(-100%);
      }
    }
  }

  &.right {
    .drawer-content {
      height: 100% !important;
      width: 80%;
      top: 0;
      left: auto;
      right: 0;
      bottom: 0;

      &.round {
        border-top-left-radius: 10px;
        border-bottom-left-radius: 10px;
      }


    }

    &.is-open {

      .drawer-content {
        transform: translateX(0);
      }
    }

    &.is-close {
      .drawer-content {
        transform: translateX(100%);
      }
    }
  }
}
</style>