xiaoyan2017

xiaoyan2017 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织 www.cnblogs.com/xiaoyan2017/ 编辑
编辑

web前端开发爱好者,专注于前端h5、jquery、vue、react、angular等技术研发实战项目案例。
Q:282310962 wx:xy190310

个人动态

xiaoyan2017 发布了文章 · 1月8日

基于vue3.0+vant3聊天实例|vue3仿微信app界面

项目介绍

Vue3Chatroom 一个基于vue3.0+vant3.x搭建开发的仿微信app界面聊天实例。实现了发送消息/动图gif、图片/视频预览、网址链接查看、下拉刷新、红包/朋友圈等功能。

image

技术栈

  • 编码+技术:Vscode + Vue3.x/Vuex4.x/Vue-Router4
  • UI组件库:Vant3.x (有赞移动端vue3.0组件库)
  • 弹层组件:V3Popup(基于vue3自定义弹层组件)
  • iconfont图标:阿里字体图标库
  • 自定义顶部Navbar栏+底部Tabbar

image
image
image
image
image
image
image
image
image
image
image
image
image
image
image
image
image
image

项目结构

image

vue3.0自定义navbar+tabbar组件

项目中顶部导航条和底部tab栏均是自定义组件实现。
image
大家如果对其实现方式感兴趣,可以去看看vue2中实现方式。
vue.js实现自定义顶部导航+底部tab切换

vue3.0自定义移动端弹框

项目中各种弹框场景都是基于vue3开发的自定义弹框组件实现的。
image
v3popup一款集合多种弹框动画及类型的Vue3多功能mobile弹框组件。
vue3.0系列之自定义mobile弹框组件|vue3移动端对话框

vue.config.js基本配置

vue3中 vue.config.js 基础配置信息。

const path = require('path')

module.exports = {
    // 基本路径
    // publicPath: '/',

    // 输出文件目录
    // outputDir: 'dist',

    // assetsDir: '',

    // 环境配置
    devServer: {
        // host: 'localhost',
        // port: 8080,
        // 是否开启https
        https: false,
        // 编译完是否打开网页
        open: false,
        
        // 代理配置
        // proxy: {
        //     '^/api': {
        //         target: '<url>',
        //         ws: true,
        //         changeOrigin: true
        //     },
        //     '^/foo': {
        //         target: '<other_url>'
        //     }
        // }
    },

    // webpack配置
    chainWebpack: config => {
        // 配置路径别名
        config.resolve.alias
            .set('@', path.join(__dirname, 'src'))
            .set('@assets', path.join(__dirname, 'src/assets'))
            .set('@components', path.join(__dirname, 'src/components'))
            .set('@views', path.join(__dirname, 'src/views'))
    }
}

vue3.0主页面main.js

引入一些公共组件/样式,路由、状态管理等文件。

import { createApp } from 'vue'
import App from './App.vue'

// 引入vuex和路由配置
import store from './store'
import router from './router'

// 引入js
import '@assets/js/fontSize'

// 引入公共组件
import Plugins from './plugins'

const app = createApp(App)

app.use(store)
app.use(router)
app.use(Plugins)

app.mount('#app')

vue3.0表单验证及登录拦截

vue3中使用 getCurrentInstance 获取当前上下文,用来操作store或router。

<script>
import { reactive, inject, getCurrentInstance } from 'vue'
export default {
    components: {},
    setup() {
        const { ctx } = getCurrentInstance()

        const v3popup = inject('v3popup')
        const utils = inject('utils')
        const formObj = reactive({})

        // ...

        const handleSubmit = () => {
            if(!formObj.tel){
                Snackbar('手机号不能为空!')
            }else if(!utils.checkTel(formObj.tel)){
                Snackbar('手机号格式不正确!')
            }else if(!formObj.pwd){
                Snackbar('密码不能为空!')
            }else{
                ctx.$store.commit('SET_TOKEN', utils.setToken());
                ctx.$store.commit('SET_USER', formObj.tel);

                // ...
            }
        }

        return {
            formObj,
            handleSubmit
        }
    }
}
</script>

vue3中配置 全局钩子 实现拦截登录状态。

router.beforeEach((to, from, next) => {
    const token = store.state.token

    // 判断当前路由地址是否需要登录权限
    if(to.meta.requireAuth) {
        if(token) {
            next()
        }else {
            // 未登录授权
            V3Popup({
                content: '还未登录授权!', position: 'top', popupStyle: 'background:#fa5151;color:#fff;', time: 2,
                onEnd: () => {
                    next({ path: '/login' })
                }
            })
        }
    }else {
        next()
    }
})

vue3.0聊天模块

项目中聊天编辑器部分抽离公共组件Editor,基于div可编辑contenteditable属性实现功能。
image.png

/**
 * @Desc     Vue3.0实现文字+emoj表情混排
 * @Time     andy by 2021-01
 * @About    Q:282310962  wx:xy190310
 */
<script>
    import { ref, reactive, toRefs, watch, nextTick } from 'vue'
    export default {
        props: {
            modelValue: { type: String, default: '' }
        },
        setup(props, { emit }) {
            const editorRef = ref(null)

            const data = reactive({
                editorText: props.modelValue,
                isChange: true,
                lastCursor: null,
            })

            // ...

            // 获取光标最后位置
            const getLastCursor = () => {
                let sel = window.getSelection()
                if(sel && sel.rangeCount > 0) {
                    return sel.getRangeAt(0)
                }
            }


            // 光标处插入内容 @param html 需要插入的内容
            const insertHtmlAtCursor = (html) => {
                let sel, range
                if(window.getSelection) {
                    // IE9及其它浏览器
                    sel = window.getSelection()

                    // ##注意:判断最后光标位置
                    if(data.lastCursor) {
                        sel.removeAllRanges()
                        sel.addRange(data.lastCursor)
                    }

                    if(sel.getRangeAt && sel.rangeCount) {
                        range = sel.getRangeAt(0)
                        let el = document.createElement('div')
                        el.appendChild(html)
                        var frag = document.createDocumentFragment(), node, lastNode
                        while ((node = el.firstChild)) {
                            lastNode = frag.appendChild(node)
                        }
                        range.insertNode(frag)
                        if(lastNode) {
                            range = range.cloneRange()
                            range.setStartAfter(lastNode)
                            range.collapse(true)
                            sel.removeAllRanges()
                            sel.addRange(range)
                        }
                    }
                } else if(document.selection && document.selection.type != 'Control') {
                    // IE < 9
                    document.selection.createRange().pasteHTML(html)
                }

                // ...
            }

            return {
                ...toRefs(data),
                editorRef,

                handleInput,
                handleDel,

                // ...
            }
        }
    }
</script>

行了,基于vue3实现聊天项目就介绍到这里。希望以上分享对大家有些帮助!

最后附上一个uniapp实例项目
uniapp+vue仿抖音小视频|uni-app直播实例

image

查看原文

赞 0 收藏 0 评论 0

xiaoyan2017 发布了文章 · 1月7日

Vue3.0自定义滚动条组件|vue3模拟滚动条v3-scroll

介绍

vue3.0-scroll 一款基于vue3.x开发的pc桌面端虚拟滚动条组件。拥有简便的调用方式,支持自定义滚动条尺寸、颜色、层叠及是否自动隐藏滚动条等功能。

image

v3scroll支持水平+垂直滚动条。

image

配置 :native="true" 则会显示默认系统滚动条。

image

配置 :autohide="true" 鼠标移开则会自动隐藏滚动条。

image

快速引入

main.js 中全局引入v3scroll组件。

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'
 
// 引入滚动条组件v3scroll
import V3Scroll from './components/v3scroll'
 
createApp(App).use(V3Scroll).mount('#app')

image

使用组件

使用 <v3-scroll> 包裹住内容块即可快速生成一个虚拟滚动条组件。

<!-- 自定义参数 -->
<v3-scroll size="8" color="#09f" zIndex="2021">
    <p>显示自定义内容!</p>
</v3-scroll>
 
<!-- 事件处理 -->
<v3-scroll @scroll="handleScroll">
    <p>显示自定义内容!</p>
</v3-scroll>

vue3.0 监听元素/DOM尺寸改变,动态更新滚动条组件。

image

开发实现

  • 参数配置

v3scroll支持如下参数自定义配置。

props: {
    // 是否显示原生滚动条
    native: Boolean,
    // 是否自动隐藏滚动条
    autohide: Boolean,
    // 滚动条尺寸
    size: { type: [Number, String], default: '' },
    // 滚动条颜色
    color: String,
    // 滚动条层级
    zIndex: null
},
  • v3scroll组件模板
<template>
    <div class="vui__scrollbar" ref="ref__box" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" v-resize="handleResize">
        <div :class="['vscroll__wrap', {native: native}]" ref="ref__wrap" @scroll="handleScroll">
            <div class="vscroll__view" v-resize="handleResize">
                <slot />
            </div>
        </div>
        <div :class="['vscroll__bar vertical']" @mousedown="handleClickTrack($event, 0)" :style="{'width': parseInt(size)>=0 ? parseInt(size)+'px' : '', 'z-index': parseInt(zIndex)>=0 ? parseInt(zIndex) : ''}">
            <div class="vscroll__thumb" ref="ref__barY" :style="{'background': color, 'height': barHeight+'px'}" @mousedown="handleDragThumb($event, 0)"></div>
        </div>
        <div :class="['vscroll__bar horizontal']" @mousedown="handleClickTrack($event, 1)" :style="{'height': parseInt(size)>=0 ? parseInt(size)+'px' : '', 'z-index': parseInt(zIndex)>=0 ? parseInt(zIndex) : ''}">
            <div class="vscroll__thumb" ref="ref__barX" :style="{'background': color, 'width': barWidth+'px'}" @mousedown="handleDragThumb($event, 1)"></div>
        </div>
    </div>
</template>

image

  • v3scroll的主要逻辑处理 (Vue3语法)
/**
 * @Desc     Vue3.x美化滚动条组件V3Scroll
 * @Time     andy by 2021-01
 * @About    Q:282310962  wx:xy190310
 */
<script>
    import { onMounted, ref, reactive, toRefs, nextTick } from 'vue'
    import domUtils from './utils/dom'
    export default {
        props: {
            // ...
        },
        
        /**
         * Vue3.x自定义指令写法
         */
        // 监听DOM尺寸变化
        directives: {
            'resize': {
                beforeMount: function(el, binding) {
                    let width = '', height = '';
                    function get() {
                        const elStyle = el.currentStyle ? el.currentStyle : document.defaultView.getComputedStyle(el, null);
                        if (width !== elStyle.width || height !== elStyle.height) {
                            binding.value({width, height});
                        }
                        width = elStyle.width;
                        height = elStyle.height;
                    }
                    el.__vueReize__ = setInterval(get, 16);
                },
                unmounted: function(el) {
                    clearInterval(el.__vueReize__);
                }
            }
        },
        setup(props, context) {
            const ref__box = ref(null)
            const ref__wrap = ref(null)
            const ref__barX = ref(null)
            const ref__barY = ref(null)
 
            const data = reactive({
                barWidth: 0,            // 滚动条宽度
                barHeight: 0,           // 滚动条高度
                ratioX: 1,              // 滚动条水平偏移率
                ratioY: 1,              // 滚动条垂直偏移率
                isTaped: false,         // 鼠标光标是否按住滚动条
                isHover: false,         // 鼠标光标是否悬停在滚动区
                isShow: !props.autohide, // 是否显示滚动条
            })
 
            onMounted(() => {
                nextTick(() => {
                    updated()
                })
            })
 
            // 鼠标滑入
            const handleMouseEnter = () => {
                data.isHover = true
                data.isShow = true
                updated()
            }
 
            // 鼠标滑出
            const handleMouseLeave = () => {
                data.isHover = false
                data.isShow = false
            }
 
            // 拖动滚动条
            const handleDragThumb = (e, index) => {
                const elWrap = ref__wrap.value
                const elBarX = ref__barX.value
                const elBarY = ref__barY.value
 
                data.isTaped = true
                let c = {}
                // 阻止默认事件
                domUtils.isIE() ? (e.returnValue = false, e.cancelBubble = true) : (e.stopPropagation(), e.preventDefault())
                document.onselectstart = () => false
 
                if(index == 0) {
                    c.dragY = true
                    c.clientY = e.clientY
                }else {
                    c.dragX = true
                    c.clientX = e.clientX
                }
 
                // ...
            }
 
            // 点击滚动槽
            const handleClickTrack = (e, index) => {
                // ...
            }
 
            // 更新滚动区
            const updated = () => {
                if(props.native) return
                const elBox = ref__box.value
                const elWrap = ref__wrap.value
                const elBarX = ref__barX.value
                const elBarY = ref__barY.value
 
                let barSize = domUtils.getScrollBarSize()
 
                // 垂直滚动条
                if(elWrap.scrollHeight > elWrap.offsetHeight) {
                    data.barHeight = elBox.offsetHeight **2 / elWrap.scrollHeight
                    data.ratioY = (elWrap.scrollHeight - elBox.offsetHeight) / (elBox.offsetHeight - data.barHeight)
                    elBarY.style.transform = `translateY(${elWrap.scrollTop / data.ratioY}px)`
                    // 隐藏系统滚动条
                    if(barSize) {
                        elWrap.style.marginRight = -barSize + 'px'
                    }
                }else {
                    data.barHeight = 0
                    elBarY.style.transform = ''
                    elWrap.style.marginRight = ''
                }
 
                // 水平滚动条
                // ...
            }
 
            // 滚动区元素/DOM尺寸改变
            const handleResize = () => {
                // 执行更新操作
            }
 
            // ...
 
            return {
                ...toRefs(data),
                ref__box, ref__wrap, ref__barX, ref__barY,
 
                handleMouseEnter, handleMouseLeave,
                handleDragThumb, handleClickTrack,
                updated,
                
                // ...
            }
        }
    }
</script>

image

好了,基于vue3实现PC网页版自定义滚动条组件就分享这么多。希望对大家有所帮助哈!😀

最后附上一个Vue3自定义弹窗组件。
vue3.0自定义桌面端弹窗组件|vue3仿layer对话框v3layer

image

查看原文

赞 0 收藏 0 评论 0

xiaoyan2017 发布了文章 · 1月3日

vue3.0自定义弹窗组件|vue3 pc端对话框vue3-layer

介绍

Vue3-Layer 一款基于vue3.x实现的PC桌面端弹窗组件。拥有极简的调用方式、超多的参数配置。支持拖拽、缩放、最大化、全局、自定义弹窗样式等功能。

image
image

快速引入

// 在main.js中全局引入组件
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 引入Element-Plus组件库
import ElementPlus from 'element-plus'
import 'element-plus/lib/theme-chalk/index.css'

// 引入弹窗组件v3layer
import Vue3Layer from './components/v3layer'

app.use(ElementPlus)
app.use(Vue3Layer)

app.mount('#app')

v3layer同样支持标签式+函数式两种调用方式。

  • 标签式调用
<v3-layer 
    v-model="showDialog"
    title="标题内容"
    content="<div style='color:#f57b16;padding:30px;'>这里是内容信息!</div>"
    z-index="1011"
    lockScroll="false"
    xclose
    resize
    dragOut
    :btns="[
        {text: '取消', click: () => showDialog=false},
        {text: '确认', style: 'color:#f90;', click: handleSure},
    ]"
/>
    <template v-slot:content>这里是自定义插槽内容信息!</template>
</v3-layer>
  • 函数式调用
let $el = v3layer({
    title: '标题内容',
    content: '<div style='color:#f57b16;padding:30px;'>这里是内容信息!</div>', 
    shadeClose: false,
    zIndex: 1011,
    lockScroll: false,
    xclose: true,
    resize: true,
    dragOut: true,
    btns: [
        {text: '取消', click: () => { $el.close() }},
        {text: '确认', click: () => handleSure},
    ]
});

当弹窗类型为 message | popover | notify 则调用如下:

v3layer.message({...})
v3layer.notify({...})
v3layer.popover({...})

传入自定义参数即可实现上述弹窗类型效果。

image
image

Vue3.0中提供了两种全局挂载方式 app.config.globalPropertiesapp.provide 详细使用可以去参看官网说明。
https://v3.cn.vuejs.org/api/a...
https://v3.cn.vuejs.org/guide...
另外使用的话,在这一篇文章也有些介绍。
https://segmentfault.com/a/11...

image
image
image
image
image
image
image
image

实现过程

在components目录下新建v3layer文件。

  • v3layer配置参数
|props参数|
v-model         是否显示弹框
id              弹窗唯一标识
title           标题
content         内容(支持String、带标签内容、自定义插槽内容)***如果content内容比较复杂,推荐使用标签式写法
type            弹框类型(toast|footer|actionsheet|actionsheetPicker|android|ios|contextmenu|drawer|iframe)
layerStyle      自定义弹窗样式
icon            toast图标(loading | success | fail)
shade           是否显示遮罩层
shadeClose      是否点击遮罩时关闭弹窗
lockScroll      是否弹窗出现时将body滚动锁定
opacity         遮罩层透明度
xclose          是否显示关闭图标
xposition       关闭图标位置(left | right | top | bottom)
xcolor          关闭图标颜色
anim            弹窗动画(scaleIn | fadeIn | footer | fadeInUp | fadeInDown | fadeInLeft | fadeInRight)
position        弹出位置(auto | ['100px','50px'] | t | r | b | l | lt | rt | lb | rb)
drawer          抽屉弹窗(top | right | bottom | left)
follow          跟随元素定位弹窗(支持元素.kk #kk 或 [e.clientX, e.clientY])
time            弹窗自动关闭秒数(1、2、3)
zIndex          弹窗层叠(默认8080)
teleport        指定挂载节点(默认是挂载组件标签位置,可通过teleport自定义挂载位置) teleport="body | #xxx | .xxx"
topmost         置顶当前窗口(默认false)
area            弹窗宽高(默认auto)设置宽度area: '300px' 设置高度area:['', '200px'] 设置宽高area:['350px', '150px']
maxWidth        弹窗最大宽度(只有当area:'auto'时,maxWidth的设定才有效)
maximize        是否显示最大化按钮(默认false)
fullscreen      全屏弹窗(默认false)
fixed           弹窗是否固定
drag            拖拽元素(可定义选择器drag:'.xxx' | 禁止拖拽drag:false)
dragOut         是否允许拖拽到窗口外(默认false)
lockAxis        限制拖拽方向可选: v 垂直、h 水平,默认不限制
resize          是否允许拉伸尺寸(默认false)
btns            弹窗按钮(参数:text|style|disabled|click)
++++++++++++++++++++++++++++++++++++++++++++++
|emit事件触发|
success         层弹出后回调(@success="xxx")
end             层销毁后回调(@end="xxx")
++++++++++++++++++++++++++++++++++++++++++++++
|event事件|
onSuccess       层打开回调事件
onEnd           层关闭回调事件
  • v3layer模板及逻辑处理
<template>
    <div ref="elRef" v-show="opened" class="vui__layer" :class="{'vui__layer-closed': closeCls}" :id="id">
        <!-- //蒙版 -->
        <div v-if="JSON.parse(shade)" class="vlayer__overlay" @click="shadeClicked" :style="{opacity}"></div>
        <div class="vlayer__wrap" :class="['anim-'+anim, type&&'popui__'+type, tipArrow]" :style="[layerStyle]">
            <div v-if="title" class="vlayer__wrap-tit" v-html="title"></div>
            <div v-if="type=='toast'&&icon" class="vlayer__toast-icon" :class="['vlayer__toast-'+icon]" v-html="toastIcon[icon]"></div>
            <div class="vlayer__wrap-cntbox">
                <!-- 判断插槽是否存在 -->
                <template v-if="$slots.content">
                    <div class="vlayer__wrap-cnt"><slot name="content" /></div>
                </template>
                <template v-else>
                    <template v-if="content">
                        <iframe v-if="type=='iframe'" scrolling="auto" allowtransparency="true" frameborder="0" :data-original="content"></iframe>
                        <!-- message|notify|popover -->
                        <div v-else-if="type=='message' || type=='notify' || type=='popover'" class="vlayer__wrap-cnt">
                            <i v-if="icon" class="vlayer-msg__icon" :class="icon" v-html="messageIcon[icon]"></i>
                            <div class="vlayer-msg__group"><div v-if="title" class="vlayer-msg__title" v-html="title"></div><div v-html="content"></div></div>
                        </div>
                        <div v-else class="vlayer__wrap-cnt" v-html="content"></div>
                    </template>
                </template>
                <slot />
            </div>
            <div v-if="btns" class="vlayer__wrap-btns">
                <span v-for="(btn,index) in btns" :key="index" class="btn" :style="btn.style" @click="btnClicked($event,index)" v-html="btn.text"></span>
            </div>
            <span v-if="xclose" class="vlayer__xclose" :class="!maximize&&xposition" :style="{'color': xcolor}" @click="close"></span>
            <span v-if="maximize" class="vlayer__maximize" @click="maximizeClicked($event)"></span>
            <span v-if="resize" class="vlayer__resize"></span>
        </div>
        <!-- 优化拖拽卡顿 -->
        <div class="vlayer__dragfix"></div>
    </div>
</template>
/**
 * @Desc     Vue3.0桌面端弹窗组件V3Layer
 * @Time     andy by 2021-1
 * @About    Q:282310962  wx:xy190310
 */
<script>
    import { onMounted, onUnmounted, ref, reactive, watch, toRefs, nextTick } from 'vue'
    import domUtils from './utils/dom.js'
    // 索引,蒙层控制,定时器
    let $index = 0, $locknum = 0, $timer = {}, $closeTimer = null
    export default {
        props: {
            // ...
        },
        emits: [
            'update:modelValue'
        ],
        setup(props, context) {
            const elRef = ref(null);

            const data = reactive({
                opened: false,
                closeCls: '',
                toastIcon: {
                    // ...
                },
                messageIcon: {
                    // ...
                },
                vlayerOpts: {},
                tipArrow: null,
            })

            onMounted(() => {
                if(props.modelValue) {
                    open();
                }
                window.addEventListener('resize', autopos, false);
            })

            onUnmounted(() => {
                window.removeEventListener('resize', autopos, false);
                clearTimeout($closeTimer);
            })

            // 监听弹层v-model
            watch(() => props.modelValue, (val) => {
                // console.log('V3Layer is now [%s]', val ? 'show' : 'hide')
                if(val) {
                    open();
                }else {
                    close();
                }
            })

            // 打开弹窗
            const open = () => {
                if(data.opened) return;
                data.opened = true;
                typeof props.onSuccess === 'function' && props.onSuccess();

                const dom = elRef.value;
                // 弹层挂载位置
                if(props.teleport) {
                    nextTick(() => {
                        let teleportNode = document.querySelector(props.teleport);
                        teleportNode.appendChild(dom);

                        auto();
                    })
                }

                callback();
            }

            // 关闭弹窗
            const close = () => {
                if(!data.opened) return;

                let dom = elRef.value;
                let vlayero = dom.querySelector('.vlayer__wrap');
                let ocnt = dom.querySelector('.vlayer__wrap-cntbox');
                let omax = dom.querySelector('.vlayer__maximize');

                data.closeCls = true;
                clearTimeout($closeTimer);
                $closeTimer = setTimeout(() => {
                    data.opened = false;
                    data.closeCls = false;
                    if(data.vlayerOpts.lockScroll) {
                        $locknum--;
                        if(!$locknum) {
                            document.body.style.paddingRight = '';
                            document.body.classList.remove('vui__body-hidden');
                        }
                    }
                    if(props.time) {
                        $index--;
                    }
                    // 清除弹窗样式
                    vlayero.style.width = vlayero.style.height = vlayero.style.top = vlayero.style.left = '';
                    ocnt.style.height = '';
                    omax && omax.classList.contains('maximized') && omax.classList.remove('maximized');
                    
                    data.vlayerOpts.isBodyOverflow && (document.body.style.overflow = '');

                    context.emit('update:modelValue', false);
                    typeof props.onEnd === 'function' && props.onEnd();
                }, 200)
            }

            // 弹窗位置
            const auto = () => {
                // ...

                autopos();

                // 全屏弹窗
                if(props.fullscreen) {
                    full();
                }

                // 弹窗拖动|缩放
                move();
            }

            const autopos = () => {
                if(!data.opened) return;
                let oL, oT
                let pos = props.position;
                let isFixed = JSON.parse(props.fixed);
                let dom = elRef.value;
                let vlayero = dom.querySelector('.vlayer__wrap');

                if(!isFixed || props.follow) {
                    vlayero.style.position = 'absolute';
                }
                
                let area = [domUtils.client('width'), domUtils.client('height'), vlayero.offsetWidth, vlayero.offsetHeight]
                
                oL = (area[0] - area[2]) / 2;
                oT = (area[1] - area[3]) / 2;

                if(props.follow) {
                    offset();
                }else {
                    typeof pos === 'object' ? (
                        oL = parseFloat(pos[0]) || 0, oT = parseFloat(pos[1]) || 0
                    ) : (
                        pos == 't' ? oT = 0 : 
                        pos == 'r' ? oL = area[0] - area[2] : 
                        pos == 'b' ? oT = area[1] - area[3] : 
                        pos == 'l' ? oL = 0 : 
                        pos == 'lt' ? (oL = 0, oT = 0) : 
                        pos == 'rt' ? (oL = area[0] - area[2], oT = 0) : 
                        pos == 'lb' ? (oL = 0, oT = area[1] - area[3]) :
                        pos == 'rb' ? (oL = area[0] - area[2], oT = area[1] - area[3]) : 
                        null
                    )

                    vlayero.style.left = parseFloat(isFixed ? oL : domUtils.scroll('left') + oL) + 'px';
                    vlayero.style.top = parseFloat(isFixed ? oT : domUtils.scroll('top') + oT) + 'px';
                }
            }

            // 元素跟随定位
            const offset = () => {
                let oW, oH, pS
                let dom = elRef.value
                let vlayero = dom.querySelector('.vlayer__wrap');

                oW = vlayero.offsetWidth;
                oH = vlayero.offsetHeight;
                pS = domUtils.getFollowRect(props.follow, oW, oH);
                data.tipArrow = pS[2];
                
                vlayero.style.left = pS[0] + 'px';
                vlayero.style.top = pS[1] + 'px';
            }

            // 最大化弹窗
            const full = () => {
                // ...
            }

            // 恢复弹窗
            const restore = () => {
                let dom = elRef.value;
                let vlayero = dom.querySelector('.vlayer__wrap');
                let otit = dom.querySelector('.vlayer__wrap-tit');
                let ocnt = dom.querySelector('.vlayer__wrap-cntbox');
                let obtn = dom.querySelector('.vlayer__wrap-btns');
                let omax = dom.querySelector('.vlayer__maximize');

                let t = otit ? otit.offsetHeight : 0
                let b = obtn ? obtn.offsetHeight : 0

                if(!data.vlayerOpts.lockScroll) {
                    data.vlayerOpts.isBodyOverflow = false;
                    document.body.style.overflow = '';
                }
                
                props.maximize && omax.classList.remove('maximized')
                
                vlayero.style.left = parseFloat(data.vlayerOpts.rect[0]) + 'px';
                vlayero.style.top = parseFloat(data.vlayerOpts.rect[1]) + 'px';
                vlayero.style.width = parseFloat(data.vlayerOpts.rect[2]) + 'px';
                vlayero.style.height = parseFloat(data.vlayerOpts.rect[3]) + 'px';
            }

            // 拖动|缩放弹窗
            const move = () => {
                // ...
            }

            // 事件处理
            const callback = () => {
                // 倒计时关闭
                if(props.time) {
                    $index++
                    // 防止重复点击
                    if($timer[$index] !== null) clearTimeout($timer[$index])
                    $timer[$index] = setTimeout(() => {
                        close();
                    }, parseInt(props.time) * 1000)
                }
            }

            // 点击最大化按钮
            const maximizeClicked = (e) => {
                let o = e.target
                if(o.classList.contains('maximized')) {
                    // 恢复
                    restore();
                } else {
                    // 最大化
                    full();
                }
            }
            // 点击遮罩层
            const shadeClicked = () => {
                if(JSON.parse(props.shadeClose)) {
                    close();
                }
            }
            // 按钮事件
            const btnClicked = (e, index) => {
                let btn = props.btns[index]
                if(!btn.disabled) {
                    typeof btn.click === 'function' && btn.click(e)
                }
            }
            
            return {
                ...toRefs(data),
                elRef,
                close,
                maximizeClicked,
                shadeClicked,
                btnClicked,
            }
        }
    }
</script>

大家可以在此基础上扩充一些想要的效果。
vue3.0中把弹框实例挂载到body上可通过createAppcreateVNode实现。大家感兴趣也可以看看之前分享的一篇文章。

ok,使用Vue3开发自定义alert/dialog组件就分享到这里。感谢大家的阅读!✍

image

查看原文

赞 0 收藏 0 评论 0

xiaoyan2017 发布了文章 · 2020-12-30

基于Vue3.0自定义移动端弹层组件Vue3-Popup

介绍

V3Popup 一款基于vue3.0开发手机端弹出框组件。在开发设计之初参考借鉴了Vant3Antdv2.0中弹框组件化思想。支持超过20+种参数灵活配置。

image
image

引入组件

// 在main.js中全局引入
import { createApp } from 'vue'
import App from './App.vue'

// 引入弹窗组件v3popup
import V3Popup from './components/v3popup'
 
createApp(App).use(V3Popup).mount('#app')

遵循极简的调用方式,支持组件式+函数式两种调用。

  • 组件式
<!-- 提示框 -->
<v3-popup v-model="showMsg" anim="fadeIn" content="msg提示框测试(3s后窗口关闭)" shadeClose="false" time="3" />
 
<!-- 询问框 -->
<v3-popup v-model="showConfirm" shadeClose="false" title="标题" xclose z-index="2020"
    content="<div style='color:#1be769;padding:20px;'>确认框(这里是确认框提示信息,这里确认框提示信息,这里是确认框提示信息)</div>"
    :btns="[
        {text: '取消', click: () => showConfirm=false},
        {text: '确定', style: 'color:#f90;', click: handleInfo},
    ]"
/>
  • 函数式
let $el = this.$v3popup({
    title: '标题',
    content: '<div style='color:#f90;padding:10px;'>这里是内容信息!</div>',
    type: 'android',
    shadeClose: false,
    xclose: true,
    btns: [
        {text: '取消', click: () => { $el.close(); }},
        {text: '确认', style: 'color:#f90;', click: () => handleOK},
    ],
    onSuccess: () => {},
    onEnd: () => {}
})

vue2中可以通过prototype原型链挂载全局方法。vue3如果挂载全局方法呢?
其实vue3中提供了两种方式来挂载全局方法。
app.config.globalPropertiesapp.provide 两种方式。
1、通过app.config.globalProperties.$v3popup = V3Popup

// vue2中调用
methods: {
    showDialog() {
        this.$v3popup({...})
    }
}
 
// vue3中调用
setup() {
    // 获取上下文
    const { ctx } = getCurrentInstance()
    ctx.$v3popup({...})
}

2、通过app.provide('v3popup', V3Popup)

// vue2中调用
methods: {
    showDialog() {
        this.v3popup({...})
    }
}
 
// vue3中调用
setup() {
    const v3popup = inject('v3popup')
    
    const showDialog = () => {
        v3popup({...})
    }
 
    return {
        v3popup,
        showDialog
    }
}

不过vue作者更推荐第二种方式挂载方法。

预览图

image
image
image
image
image
image
image
image
image
image

参数配置

v3popup支持如下参数混合搭配使用。

|props参数|
v-model         是否显示弹框
title           标题
content         内容(支持String、带标签内容、自定义插槽内容)***如果content内容比较复杂,推荐使用标签式写法
type            弹窗类型(toast | footer | actionsheet | actionsheetPicker | android | ios)
popupStyle      自定义弹窗样式
icon            toast图标(loading | success | fail)
shade           是否显示遮罩层
shadeClose      是否点击遮罩时关闭弹窗
opacity         遮罩层透明度
round           是否显示圆角
xclose          是否显示关闭图标
xposition       关闭图标位置(left | right | top | bottom)
xcolor          关闭图标颜色
anim            弹窗动画(scaleIn | fadeIn | footer | fadeInUp | fadeInDown)
position        弹出位置(top | right | bottom | left)
follow          长按/右键弹窗(坐标点)
time            弹窗自动关闭秒数(1、2、3)
zIndex          弹窗层叠(默认8080)
teleport        指定挂载节点(默认是挂载组件标签位置,可通过teleport自定义挂载位置) teleport="body | #xxx | .xxx"
btns            弹窗按钮(参数:text|style|disabled|click)
++++++++++++++++++++++++++++++++++++++++++++++
|emit事件触发|
success         层弹出后回调(@success="xxx")
end             层销毁后回调(@end="xxx")
++++++++++++++++++++++++++++++++++++++++++++++
|event事件|
onSuccess       层打开回调事件
onEnd           层关闭回调事件
<template>
    <div ref="elRef" v-show="opened" class="vui__popup" :class="{'vui__popup-closed': closeCls}" :id="id">
        <!-- //蒙层 -->
        <div v-if="JSON.parse(shade)" class="vui__overlay" @click="shadeClicked" :style="{opacity}"></div>
        <div class="vui__wrap">
            <div class="vui__wrap-section">
                <div class="vui__wrap-child" :class="['anim-'+anim, type&&'popupui__'+type, round&&'round', position]" :style="[popupStyle]">
                    <div v-if="title" class="vui__wrap-tit" v-html="title"></div>
                    <div v-if="type=='toast'&&icon" class="vui__toast-icon" :class="['vui__toast-'+icon]" v-html="toastIcon[icon]"></div>
                    <!-- 判断插槽是否存在 -->
                    <template v-if="$slots.content">
                        <div class="vui__wrap-cnt"><slot name="content" /></div>
                    </template>
                    <template v-else>
                        <div v-if="content" class="vui__wrap-cnt" v-html="content"></div>
                    </template>
                    <slot />
                    <div v-if="btns" class="vui__wrap-btns">
                        <span v-for="(btn, index) in btns" :key="index" class="btn" :style="btn.style" @click="btnClicked($event, index)" v-html="btn.text"></span>
                    </div>
                    <span v-if="xclose" class="vui__xclose" :class="xposition" :style="{'color': xcolor}" @click="close"></span>
                </div>
            </div>
        </div>
    </div>
</template>
 
/**
 * @Desc     Vue3.0自定义弹层V3Popup
 * @Time     andy by 2020-12
 * @About    Q:282310962  wx:xy190310
 */
<script>
    import { onMounted, ref, reactive, watch, toRefs, nextTick } from 'vue'
    let $index = 0, $locknum = 0, $timer = {}
    export default {
        props: {
            // 接收父组件v-model值,如果v-model:open,则这里需写open: {...}
            modelValue: { type: Boolean, default: false },
            // 标识符,相同ID共享一个实例
            id: {
                type: String, default: ''
            },
            title: String,
            content: String,
            type: String,
            popupStyle: String,
            icon: String,
            shade: { type: [Boolean, String], default: true },
            shadeClose: { type: [Boolean, String], default: true },
            opacity: { type: [Number, String], default: '' },
            round: Boolean,
            xclose: Boolean,
            xposition: { type: String, default: 'right' },
            xcolor: { type: String, default: '#333' },
            anim: { type: String, default: 'scaleIn' },
            position: String,
            follow: { type: Array, default: null },
            time: { type: [Number, String], default: 0 },
            zIndex: { type: [Number, String], default: '8080' },
            teleport: [String, Object],
            btns: {
                type: Array, default: null
            },
            onSuccess: { type: Function, default: null },
            onEnd: { type: Function, default: null },
        },
        emits: [
            'update:modelValue'
        ],
        setup(props, context) {
            const elRef = ref(null)
 
            const data = reactive({
                opened: false,
                closeCls: '',
                toastIcon: {
                    ...
                }
            })
 
            onMounted(() => {
                ...
            })
 
            // 监听弹层v-model
            watch(() => props.modelValue, (val) => {
                if(val) {
                    open()
                }else {
                    close()
                }
            })
 
            // 打开弹层
            const open = () => {
                if(data.opened) return
                data.opened = true
                typeof props.onSuccess === 'function' && props.onSuccess()
 
                const dom = elRef.value
                dom.style.zIndex = getZIndex() + 1
 
                ...
 
                // 倒计时
                if(props.time) {
                    $index++
                    // 避免重复操作
                    if($timer[$index] !== null) clearTimeout($timer[$index])
                    $timer[$index] = setTimeout(() => {
                        close()
                    }, parseInt(props.time) * 1000)
                }
 
                // 长按|右键菜单
                if(props.follow) {
                    ...
                }
            }
 
            // 关闭弹层
            const close = () => {
                if(!data.opened) return
 
                data.closeCls = true
                setTimeout(() => {
                    ...
 
                    context.emit('update:modelValue', false)
                    typeof props.onEnd === 'function' && props.onEnd()
                }, 200)
            }
 
            // 点击遮罩层
            const shadeClicked = () => {
                if(JSON.parse(props.shadeClose)) {
                    close()
                }
            }
            // 按钮事件
            const btnClicked = (e, index) => {
                let btn = props.btns[index];
                if(!btn.disabled) {
                    typeof btn.click === 'function' && btn.click(e)
                }
            }
            
            ...
 
            return {
                ...toRefs(data),
                elRef,
                close,
                shadeClicked,
                btnClicked,
            }
        }
    }
</script>

vue3中如何把弹框实例挂载到body上呢?其实可通过createAppcreateVNode实现。
createApp第一个参数传入组件模板,第二个参数传入自定义参数即可。

import { createApp } from 'vue'
import PopupConstructor from './popup.vue'
 
let $inst
// 创建挂载实例
let createMount = (opts) => {
    const mountNode = document.createElement('div')
    document.body.appendChild(mountNode)
 
    const app = createApp(PopupConstructor, {
        ...opts, modelValue: true,
        remove() {
            app.unmount(mountNode)
            document.body.removeChild(mountNode)
        }
    })
    return app.mount(mountNode)
}
 
function V3Popup(options = {}) {
    options.id = options.id || 'v3popup_' + generateId()
    $inst = createMount(options)
    
    return $inst
}
 
V3Popup.install = app => {
    app.component('v3-popup', PopupConstructor)
    // app.config.globalProperties.$v3popup = V3Popup
    app.provide('v3popup', V3Popup)
}

通过如上方法就可以实现函数式调用组件了。

okey,基于Vue3.x开发自定义弹出层组件就分享到这里。希望能喜欢~✍

image.png

查看原文

赞 0 收藏 0 评论 0

xiaoyan2017 发布了文章 · 2020-12-28

基于Next.js+React仿微信桌面端|next.js聊天室

项目介绍

Next.js一款比较热门的React服务器端渲染框架。让你的网页拥有SEO能力。
Next-Webchat基于next+react+redux+antd+rlayer等技术实现的仿微信PC网页端聊天项目。实现了消息表情混合发送、图片/视频预览、拖拽/粘贴截图发送、红包/朋友圈等功能。

image.png
image.png

技术框架

  • 技术架构:next.js+react+Rredux
  • UI组件库:Antd (蚂蚁金服react组件库)
  • 字体图标:阿里iconfont图标库
  • 弹窗组件:RLayer(react.js自定义对话框)
  • 虚拟滚动:RScroll(react.js自定义滚动条)

效果预览
image
image
image
image
image
image
image
image
image
image
image
image
image
image
image

如果想让你的网页也能拥有SEO功能,那么Next.js是一个不错的选择。只要会React,上手就很容易了。
image.png
https://www.nextjs.cn/
https://github.com/vercel/nex...

Next|React自定义弹窗组件

项目中用到的所有弹窗均是自己开发的RLayer组件。
RLayer 基于react.js开发的PC端自定义弹框组件。支持超过30+参数自由配置,通过轻巧的布局设计、极简的调用方式来解决复杂的弹出层功能,为您呈上不一样的弹窗效果。
image.png
感兴趣的话,可以去看看之前的这篇分享文章。
https://segmentfault.com/a/11...

Next|React自定义滚动条组件

项目中用到的滚动条也是自己开发的一款PC桌面端虚拟美化滚动条组件RScroll。
image.png
Rscroll.js支持原生滚动条、是否自动隐藏、滚动条大小/层级/颜色等功能。

Next公共模板

新建layouts/index.js文件用于页面主入口模板。

function Layout(props) {
    const router = useRouter()

    // 拦截验证
    useEffect(() => {
        // ...
    }, [])

    return (
    <>
        {/* 配置公共head信息 */}
        <Head>
            <title>Next.js聊天室</title>
            <link rel="icon" href="/favicon.ico" />
            <meta name="keywords" content="Next.js|React.js|Next.js聊天室|Next.js仿微信|React聊天实例"></meta>
            <meta name="description" content="Next-WebChat 基于Next.js+React+Redux构建的服务端渲染聊天应用程序"></meta>
        </Head>

        <div className="next__container flexbox flex-alignc flex-justifyc">
            <div className={utils.classNames('next__wrapper')} style={{ backgroundImage: `url(${props.skin})` }}>
                <div className="next__board flexbox">
                    {/* 右上角按钮 */}
                    <WinBar {...props} />

                    {/* 侧边栏 */}
                    <Sidebar {...props} />

                    {/* 中间栏 */}
                    <Middle />

                    {/* 主体布局 */}
                    <div className="nt__mainbox flex1 flexbox flex-col">
                        {props.children}
                    </div>
                </div>
            </div>
        </div>
    </>
    )
}

Head组件用于配置一些页面SEO信息,如:title、keyword、description及图标icon等信息。

聊天部分

编辑器模块单独抽离出来封装了一个editor组件,用于处理一些聊天输入、表情、光标处插入内容、粘贴截图等功能。
image.png

// react中实现div的contenteditable功能
return (
    <div 
        ref={editorRef}
        className="editor"
        contentEditable="true"
        dangerouslySetInnerHTML={{__html: state.editorText}}
        onClick={handleClicked}
        onInput={handleInput}
        onFocus={handleFocus}
        onBlur={handleBlur}
        style={{userSelect: 'text', WebkitUserSelect: 'text'}}>
    </div>
)

image.png
如上图:利用RLayer弹窗实现视频播放预览功能。

handlePlayVideo = (item, e) => {
    rlayer({
        content: (
            <div className="flexbox flex-col" style={{height: '100%'}}>
                <div className="ntDrag__head"><i className="iconfont icon-bofang"></i> 视频预览</div>
                <div className="ntMain__cont flex1 flexbox flex-col">
                    {/* 视频video */}
                    <video className="vplayer" data-original={item.videosrc} poster={item.imgsrc} autoPlay preload="auto" controls
                        x5-video-player-fullscreen="true"
                        webkit-playsinline="true"
                        x-webkit-airplay="true"
                        playsInline
                        x5-playsinline="true"
                        style={{height: '100%', width: '100%', objectFit: 'contain', outline: 'none'}}
                    />
                </div>
            </div>
        ),
        layerStyle: {background: '#f6f5ef'},
        opacity: .2,
        area: ['550px', '450px'],
        drag: '.ntDrag__head',
        resize: true,
        maximize: true,
    })
}

编辑器支持拖拽发送图片。通过处理onDragEnter、onDragOver、onDrop等事件。

handleDragEnter = (e) => {
    e.stopPropagation()
    e.preventDefault()
}
handleDragOver = (e) => {
    e.stopPropagation()
    e.preventDefault()
}
handleDrop = (e) => {
    e.stopPropagation()
    e.preventDefault()
    console.log(e.dataTransfer)

    this.handleFileList(e.dataTransfer)
}
// 获取拖拽文件列表
handleFileList = (filelist) => {
    let files = filelist.files
    if(files.length >= 2) {
        rlayer.message({icon: 'error', content: '暂时支持拖拽一张图片'})
        return false
    }
    for(let i = 0; i < files.length; i++) {
        if(files[i].type != '') {
            this.handleFileAdd(files[i])
        }else {
            rlayer.message({icon: 'error', content: '目前不支持文件夹拖拽功能'})
        }
    }
}
handleFileAdd = (file) => {
    if(file.type.indexOf('image') == -1) {
        rlayer.message({icon: 'error', content: '目前不支持非图片拖拽功能'})
    }else {
        let reader = new FileReader()
        reader.readAsDataURL(file)
        reader.onload = function() {
            let img = this.result

            console.log(img)
        }
    }
}

编辑器还支持粘贴截图发送图片功能,监听paste粘贴事件。

/**
 * 编辑器模块
 */

import { useState, useRef, forwardRef, useEffect, useImperativeHandle } from 'react'

const Editor = forwardRef(({value, onInput, onFocus, onBlur, onPaste}, ref) => {
    const [state, setState] = useState({
        editorText: value,
        // 记录最后光标位置
        lastRange: null
    })
    const editorRef = useRef()

    useEffect(() => {
        // 编辑器粘贴事件
        if(!editorRef.current) return
        editorRef.current.addEventListener('paste', function(e) {
            let cbd = e.clipboardData
            let ua = window.navigator.userAgent
            if(!(e.clipboardData && e.clipboardData.items)) return

            if(cbd.items && cbd.items.length === 2 && cbd.items[0].kind === "string" && cbd.items[1].kind === "file" &&
                cbd.types && cbd.types.length === 2 && cbd.types[0] === "text/plain" && cbd.types[1] === "Files" &&
                ua.match(/Macintosh/i) && Number(ua.match(/Chrome\/(\d{2})/i)[1]) < 49){
                return;
            }
            for(var i = 0; i < cbd.items.length; i++) {
                var item = cbd.items[i]
                // console.log(item)
                // console.log(item.kind)
                if(item.kind == 'file') {
                    var blob = item.getAsFile()
                    if(blob.size === 0) return
                    // 读取图片记录
                    var reader = new FileReader()
                    reader.readAsDataURL(blob)
                    reader.onload = function() {
                        var _img = this.result

                        // 返回图片给父组件
                        typeof onPaste == 'function' && onPaste(_img)
                    }
                }
            }
        })
    }, [])
    
    // ...
})

export default Editor

Okey,基于Next.js+React开发聊天项目就分享到这里。希望大家能喜欢!✍✍
image
最后附上个Nuxt.js项目实例
Nuxt.js聊天室|nuxt+vue仿微信App实例聊天
image.png

查看原文

赞 5 收藏 3 评论 1

xiaoyan2017 发布了文章 · 2020-12-05

React.js 全局公共弹框(RLayer)|react弹窗组件

前言

RLayer.js 一个react构建的桌面PC端自定义Dialog组件内置30+参数配置、10+弹框类型、7+动画效果,提供极简的接口及清爽的皮肤。拥有顺滑般最大化/缩放/拖拽体验!

image
image

RLayer在设计及开发上参考了之前的VLayer弹出框组件。在效果上保持一致性。
vlayer一款vue2.x开发的网页弹框组件,感兴趣的可以去看看这篇文章。
https://segmentfault.com/a/11...

image

引入使用

在需要用到弹出框的页面引入rlayer组件即可。

// 引入RLayer
import rlayer from './components/rlayer';

提供了非常简易的调用写法 rlayer({...})

showConfirm = () => {
    let $rlayer = rlayer({
        title: '标题信息',
        content: "<div style='color:#0070f3;padding:50px;'>显示弹窗内容。</div>",
        shadeClose: true,
        zIndex: 2021,
        lockScroll: true,
        resize: true,
        dragOut: false,
        btns: [
            {
                text: '取消',
                click: () => {
                    $rlayer.close()
                }
            },
            {
                text: '确定',
                style: {color: '#09f'},
                click: () => {
                    // ...
                }
            }
        ]
    })
}

注意:如果弹框类型为 message|notify|popover,则需要使用如下调用方式。

rlayer.message({...})
rlayer.notify({...})
rlayer.popover({...})

一睹效果

image
image
image
image
image
image
image
image
image
image
image
image
image
image
image

编码实现

rlayer支持如下丰富的参数配置。

/**
 * 弹出框参数配置
 */
static defaultProps = {
    // 参数
    id: '',                       // {string} 控制弹层唯一标识,相同id共享一个实例
    title: '',                    // {string} 标题
    content: '',                  // {string|element} 内容(支持字符串或组件)
    type: '',                     // {string} 弹框类型(toast|footer|actionsheet|actionsheetPicker|android|ios|contextmenu|drawer|iframe)
    layerStyle: '',               // {object} 自定义弹框样式
    icon: '',                     // {string} Toast图标(loading|success|fail)
    shade: true,                  // {bool} 是否显示遮罩层
    shadeClose: true,             // {bool} 是否点击遮罩层关闭弹框
    lockScroll: true,             // {bool} 是否弹框显示时将body滚动锁定
    opacity: '',                  // {number|string} 遮罩层透明度
    xclose: true,                 // {bool} 是否显示关闭图标
    xposition: 'right',           // {string} 关闭图标位置(top|right|bottom|left)
    xcolor: '#333',               // {string} 关闭图标颜色
    anim: 'scaleIn',              // {string} 弹框动画(scaleIn|fadeIn|footer|fadeInUp|fadeInDown|fadeInLeft|fadeInRight)
    position: 'auto',             // {string|array} 弹框位置(auto|['150px','100px']|t|r|b|l|lt|rt|lb|rb)
    drawer: '',                   // {string} 抽屉弹框(top|right|bottom|left)
    follow: null,                 // {string|array} 跟随定位弹框(支持.xxx #xxx 或 [e.clientX,e.clientY])
    time: 0,                      // {number} 弹框自动关闭秒数(1|2|3...)
    zIndex: 8090,                 // {number} 弹框层叠
    topmost: false,               // {bool} 是否置顶当前弹框
    area: 'auto',                 // {string|array} 弹框宽高(auto|'250px'|['','200px']|['650px','300px'])
    maxWidth: 375,                // {number} 弹框最大宽度(只有当area:'auto'时设定才有效)
    maximize: false,              // {bool} 是否显示最大化按钮
    fullscreen: false,            // {bool} 是否全屏弹框
    fixed: true,                  // {bool} 是否固定弹框
    drag: '.rlayer__wrap-tit',    // {string|bool} 拖拽元素(可自定义拖动元素drag:'#xxx' 禁止拖拽drag:false)
    dragOut: false,               // {bool} 是否允许拖拽到浏览器外
    lockAxis: null,               // {string} 限制拖拽方向可选: v 垂直、h 水平,默认不限制
    resize: false,                // {bool} 是否允许拉伸弹框
    btns: null,                   // {array} 弹框按钮(参数:text|style|disabled|click)
 
    // 事件
    success: null,                // {func} 层弹出后回调
    end: null,                    // {func} 层销毁后回调
}

rlayer弹框模板

render() {
    let opt = this.state
 
    return (
        <>
        <div className={domUtils.classNames('rui__layer', {'rui__layer-closed': opt.closeCls})} id={opt.id} style={{display: opt.opened?'block':'none'}}>
            {/* 遮罩 */}
            { opt.shade && <div className="rlayer__overlay" onClick={this.shadeClicked} style={{opacity: opt.opacity}}></div> }
            <div className={domUtils.classNames('rlayer__wrap', opt.anim&&'anim-'+opt.anim, opt.type&&'popui__'+opt.type)} style={{...opt.layerStyle}}>
            { opt.title && <div className='rlayer__wrap-tit' dangerouslySetInnerHTML={{__html: opt.title}}></div> }
            <div className='rlayer__wrap-cntbox'>
                { opt.content ? 
                <>
                    {
                    opt.type == 'iframe' ? 
                    (
                        <iframe scrolling='auto' allowtransparency='true' frameBorder='0' data-original={opt.content}></iframe>
                    )
                    : 
                    (opt.type == 'message' || opt.type == 'notify' || opt.type == 'popover') ? 
                    (
                        <div className='rlayer__wrap-cnt'>
                        { opt.icon && <i className={domUtils.classNames('rlayer-msg__icon', opt.icon)} dangerouslySetInnerHTML={{__html: opt.messageIcon[opt.icon]}}></i> }
                        <div className='rlayer-msg__group'>
                            { opt.title && <div className='rlayer-msg__title' dangerouslySetInnerHTML={{__html: opt.title}}></div> }
                            { typeof opt.content == 'string' ? 
                            <div className='rlayer-msg__content' dangerouslySetInnerHTML={{__html: opt.content}}></div>
                            :
                            <div className='rlayer-msg__content'>{opt.content}</div>
                            }
                        </div>
                        </div>
                    )
                    : 
                    (
                        typeof opt.content == 'string' ? 
                        (<div className='rlayer__wrap-cnt' dangerouslySetInnerHTML={{__html: opt.content}}></div>)
                        :
                        opt.content
                    )
                    }
                </>
                :
                null
                }
            </div>
            { opt.btns && <div className='rlayer__wrap-btns'>
                {
                    opt.btns.map((btn, index) => {
                        return <span className={domUtils.classNames('btn')} key={index} style={{...btn.style}} dangerouslySetInnerHTML={{__html: btn.text}}></span>
                    })
                }
                </div>
            }
            { opt.xclose && <span className={domUtils.classNames('rlayer__xclose')}></span> }
            { opt.maximize && <span className='rlayer__maximize'></span> }
            { opt.resize && <span className='rlayer__resize'></span> }
            </div>
            <div className='rlayer__dragfix'></div>
        </div>
        </>
    )
}
/**
 * @Desc     ReactJs|Next.js自定义弹窗组件RLayer
 * @Time     andy by 2020-12-04
 * @About    Q:282310962  wx:xy190310
 */
import React from 'react'
import ReactDOM from 'react-dom'
 
// 引入操作类
import domUtils from './utils/dom'
 
let $index = 0, $lockCount = 0, $timer = {}
 
class RLayerComponent extends React.Component {
    static defaultProps = {
        // ...
    }
 
    constructor(props) {
        super(props)
        this.state = {
            opened: false,
            closeCls: '',
            toastIcon: {
                // ...
            },
            messageIcon: {
                // ...
            },
            rlayerOpts: {},
            tipArrow: null,
        }
 
        this.closeTimer = null
    }
 
    componentDidMount() {
        window.addEventListener('resize', this.autopos, false)
    }
    componentWillUnmount() {
        window.removeEventListener('resize', this.autopos, false)
        clearTimeout(this.closeTimer)
    }
 
    /**
     * 打开弹框
     */
    open = (options) => {
        options.id = options.id || `rlayer-${domUtils.generateId()}`
 
        this.setState({
            ...this.props, ...options, opened: true,
        }, () => {
            const { success } = this.state
            typeof success === 'function' && success.call(this)
 
            this.auto()
            this.callback()
        })
    }
 
    /**
     * 关闭弹框
     */
    close = () => {
        const { opened, time, end, remove, rlayerOpts, action } = this.state
        if(!opened) return
 
        this.setState({ closeCls: true })
        clearTimeout(this.closeTimer)
        this.closeTimer = setTimeout(() => {
            this.setState({
                closeCls: false,
                opened: false,
            })
            if(rlayerOpts.lockScroll) {
                $lockCount--
                if(!$lockCount) {
                    document.body.style.paddingRight = ''
                    document.body.classList.remove('rc-overflow-hidden')
                }
            }
            if(time) {
                $index--
            }
            if(action == 'update') {
                document.body.style.paddingRight = ''
                document.body.classList.remove('rc-overflow-hidden')
            }
            rlayerOpts.isBodyOverflow && (document.body.style.overflow = '')
            remove()
            typeof end === 'function' && end.call(this)
        }, 200);
    }
 
    // 弹框位置
    auto = () => {
        // ...
 
        this.autopos()
 
        // 全屏弹框
        if(fullscreen) {
            this.full()
        }
 
        // 弹框拖拽|缩放
        this.move()
    }
 
    autopos = () => {
        const { opened, id, fixed, follow, position } = this.state
        if(!opened) return
        let oL, oT
        let dom = document.querySelector('#' + id)
        let rlayero = dom.querySelector('.rlayer__wrap')
 
        if(!fixed || follow) {
            rlayero.style.position = 'absolute'
        }
 
        let area = [domUtils.client('width'), domUtils.client('height'), rlayero.offsetWidth, rlayero.offsetHeight]
 
        oL = (area[0] - area[2]) / 2
        oT = (area[1] - area[3]) / 2
 
        if(follow) {
            this.offset()
        } else {
            typeof position === 'object' ? (
                oL = parseFloat(position[0]) || 0, oT = parseFloat(position[1]) || 0
            ) : (
                position == 't' ? oT = 0 : 
                position == 'r' ? oL = area[0] - area[2] : 
                position == 'b' ? oT = area[1] - area[3] : 
                position == 'l' ? oL = 0 : 
                position == 'lt' ? (oL = 0, oT = 0) : 
                position == 'rt' ? (oL = area[0] - area[2], oT = 0) : 
                position == 'lb' ? (oL = 0, oT = area[1] - area[3]) :
                position == 'rb' ? (oL = area[0] - area[2], oT = area[1] - area[3]) : 
                null
            )
 
            rlayero.style.left = parseFloat(fixed ? oL : domUtils.scroll('left') + oL) + 'px'
            rlayero.style.top = parseFloat(fixed ? oT : domUtils.scroll('top') + oT) + 'px'
        }
    }
 
    // 跟随元素定位
    offset = () => {
        const { id, follow } = this.state
        let oW, oH, pS
        let dom = document.querySelector('#' + id)
        let rlayero = dom.querySelector('.rlayer__wrap')
 
        oW = rlayero.offsetWidth
        oH = rlayero.offsetHeight
        pS = domUtils.getFollowRect(follow, oW, oH)
 
        rlayero.style.left = pS[0] + 'px'
        rlayero.style.top = pS[1] + 'px'
    }
 
    // 最大化弹框
    full = () => {
        // ...
    }
 
    // 恢复弹框
    restore = () => {
        // ...
    }
 
    // 拖拽|缩放弹框
    move = () => {
        // ...
    }
 
    // 事件处理
    callback = () => {
        const { time } = this.state
        // 倒计时关闭弹框
        if(time) {
            $index++
            // 防止重复计数
            if($timer[$index] != null) clearTimeout($timer[$index])
            $timer[$index] = setTimeout(() => {
                this.close()
            }, parseInt(time) * 1000);
        }
    }
 
    // 点击最大化按钮
    maximizeClicked = (e) => {
        let o = e.target
        if(o.classList.contains('maximized')) {
            // 恢复
            this.restore()
        } else {
            // 最大化
            this.full()
        }
    }
 
    // 点击遮罩层
    shadeClicked = () => {
        if(this.state.shadeClose) {
            this.close()
        }
    }
 
    // 按钮事件
    btnClicked = (index, e) => {
        let btn = this.state.btns[index]
        if(!btn.disabled) {
            typeof btn.click === 'function' && btn.click(e)
        }
    }
 
    render() {
        let opt = this.state
        return (
            <>
            <div className={domUtils.classNames('rui__layer', {'rui__layer-closed': opt.closeCls})} id={opt.id} style={{display: opt.opened?'block':'none'}}>
                { opt.shade && <div className="rlayer__overlay" onClick={this.shadeClicked} style={{opacity: opt.opacity}}></div> }
                <div className={domUtils.classNames('rlayer__wrap', opt.anim&&'anim-'+opt.anim, opt.type&&'popui__'+opt.type, opt.drawer&&'popui__drawer-'+opt.drawer, opt.xclose&&'rlayer-closable', opt.tipArrow)} style={{...opt.layerStyle}}>
                { opt.title && <div className='rlayer__wrap-tit' dangerouslySetInnerHTML={{__html: opt.title}}></div> }
                { opt.type == 'toast' && opt.icon ? <div className={domUtils.classNames('rlayer__toast-icon', 'rlayer__toast-'+opt.icon)} dangerouslySetInnerHTML={{__html: opt.toastIcon[opt.icon]}}></div> : null }
                <div className='rlayer__wrap-cntbox'>
                    { opt.content ? 
                    <>
                        {
                        opt.type == 'iframe' ? 
                        (
                            <iframe scrolling='auto' allowtransparency='true' frameBorder='0' data-original={opt.content}></iframe>
                        )
                        : 
                        (opt.type == 'message' || opt.type == 'notify' || opt.type == 'popover') ? 
                        (
                            <div className='rlayer__wrap-cnt'>
                            { opt.icon && <i className={domUtils.classNames('rlayer-msg__icon', opt.icon)} dangerouslySetInnerHTML={{__html: opt.messageIcon[opt.icon]}}></i> }
                            <div className='rlayer-msg__group'>
                                { opt.title && <div className='rlayer-msg__title' dangerouslySetInnerHTML={{__html: opt.title}}></div> }
                                { typeof opt.content == 'string' ? 
                                <div className='rlayer-msg__content' dangerouslySetInnerHTML={{__html: opt.content}}></div>
                                :
                                <div className='rlayer-msg__content'>{opt.content}</div>
                                }
                            </div>
                            </div>
                        )
                        : 
                        (
                            typeof opt.content == 'string' ? 
                            (<div className='rlayer__wrap-cnt' dangerouslySetInnerHTML={{__html: opt.content}}></div>)
                            :
                            opt.content
                        )
                        }
                    </>
                    :
                    null
                    }
                </div>
                {/* btns */}
                { opt.btns && <div className='rlayer__wrap-btns'>
                    {
                        opt.btns.map((btn, index) => {
                            return <span className={domUtils.classNames('btn')} key={index} style={{...btn.style}} dangerouslySetInnerHTML={{__html: btn.text}}></span>
                        })
                    }
                    </div>
                }
                { opt.xclose && <span className={domUtils.classNames('rlayer__xclose')} style={{color: opt.xcolor}}></span> }
                { opt.maximize && <span className='rlayer__maximize'></span> }
                { opt.resize && <span className='rlayer__resize'></span> }
                </div>
                <div className='rlayer__dragfix'></div>
            </div>
            </>
        )
    }
}

动态className

在react.js中动态绑定class类名。有如下几种常用方法。

// 字符串拼接
<i className={["rlayer"+" "+item.icon]} ></i>

// 判断
<i className={["rlayer ",isOK ? item.icon : '' ].join('')} ></i>

// ES6模板字符串
<i className={`rlayer ${isOK ? item.icon : '' }`} ></i>

这种方法简单的还行,复杂的拼接就麻烦,而且会生成很多莫名的空格。
这里采用了React classnames库。
看看如下的使用方法,就知道有多方便。

classNames('foo', 'bar'); // => 'foo bar'
classNames('foo', { bar: true }); // => 'foo bar'
classNames({ 'foo-bar': true }); // => 'foo-bar'
classNames({ 'foo-bar': false }); // => ''
classNames({ foo: true }, { bar: true }); // => 'foo bar'
classNames({ foo: true, bar: true }); // => 'foo bar'

// lots of arguments of various types
classNames('foo', { bar: true, duck: false }, 'baz', { quux: true }); // => 'foo bar baz quux'

// other falsy values are just ignored
classNames(null, false, 'bar', undefined, 0, 1, { baz: null }, ''); // => 'bar 1'

rlayer组件支持自定义拖拽把手 drag:'#aaa',是否可以拖动到窗口外部 dragOut:true
另外还支持iframe弹框,只需设置 type:'iframe',content传入网址就行。
配置 fullscreen:true 即可打开弹框就显示全屏。

image

好了,基于React.js开发pc端弹窗组件就分享到这里。希望对大家有些帮助哈!✍✍

ending,附上两个vue.js示例项目
vue|nuxt.js仿微信app聊天实例:https://segmentfault.com/a/11...
vue.js自定义虚拟化滚动条组件:https://segmentfault.com/a/11...

image

查看原文

赞 2 收藏 2 评论 2

xiaoyan2017 发布了文章 · 2020-12-01

Vue.js 桌面端虚拟滚动条|vue美化滚动条VScroll

介绍

VScroll 一款基于vue2.x构建的桌面PC端自定义模拟滚动条组件。支持自定义是否原生滚动条、自动隐藏、滚动条大小、层级及颜色等功能。拥有丝滑般的原生滚动条体验!

image
image

除了垂直滚动条,同样的也支持水平滚动条。

image
image

使用起来非常简单,只需 <v-scroll></v-scroll> 即可快速生成一个虚拟的模拟滚动条。

参数配置

props: {
    // 是否显示原生滚动条
    native: Boolean,
    // 是否自动隐藏滚动条
    autohide: Boolean,
    // 滚动条尺寸
    size: { type: [Number, String], default: '' },
    // 滚动条颜色
    color: String,
    // 滚动条层级
    zIndex: null
},

image

快速开始

在main.js中引入滚动条组件。

import VScroll from './components/vscroll';
Vue.use(VScroll);

使用组件

<!-- 支持原生滚动条 -->
<v-scroll native>
    <img data-original="https://cn.vuejs.org/images/logo.png" />
    <p>这里是自定义内容。这里是自定义内容。这里是自定义内容。</p>
</v-scroll>
 
<!-- 参数配置 -->
<v-scroll autohide size="15" color="#00a1e0" zIndex="2021">
    <img data-original="https://cn.vuejs.org/images/logo.png" />
    <p>这里是自定义内容。这里是自定义内容。这里是自定义内容。</p>
</v-scroll>

image
image

编码实现

vscroll自定义滚动条模板。

<template>
  <div class="vui__scrollbar" ref="ref__box" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" v-resize="handleResize">
    <div :class="['vscroll__wrap', {native: native}]" ref="ref__wrap" @scroll="handleScroll">
      <div class="vscroll__view" v-resize="handleResize"><slot /></div>
    </div>
    <!-- //水平|垂直滚动条 -->
    <div :class="['vscroll__bar vertical', {ishide: !isShow}]" @mousedown="handleClickTrack($event, 0)" :style="{'width': parseInt(size)>=0 ? parseInt(size)+'px' : '', 'z-index': parseInt(zIndex)>=0 ? parseInt(zIndex) : ''}">
      <div class="vscroll__thumb" ref="ref__barY" :style="{'background': color, 'height': barHeight+'px'}" @mousedown="handleDragThumb($event, 0)"></div>
    </div>
    <div :class="['vscroll__bar horizontal', {ishide: !isShow}]" @mousedown="handleClickTrack($event, 1)" :style="{'height': parseInt(size)>=0 ? parseInt(size)+'px' : '', 'z-index': parseInt(zIndex)>=0 ? parseInt(zIndex) : ''}">
      <div class="vscroll__thumb" ref="ref__barX" :style="{'background': color, 'width': barWidth+'px'}" @mousedown="handleDragThumb($event, 1)"></div>
    </div>
  </div>
</template>
/**
 * @Desc     VueJs虚拟滚动条组件VScroll
 * @Time     andy by 2020/11/30
 * @About    Q:282310962  wx:xy190310
 */
<script>
  import domUtils from './utils/dom'
  export default {
    props: {
      // 显示原生滚动条
      native: Boolean,
      // 自动隐藏滚动条
      autohide: Boolean,
      // 滚动条尺寸
      size: { type: [Number, String], default: '' },
      // 滚动条颜色
      color: String,
      // 滚动条层级
      zIndex: null
    },
    data() {
      return {
        barWidth: 0,            // 滚动条宽度
        barHeight: 0,           // 滚动条高度
        ratioX: 1,              // 滚动条水平偏移率
        ratioY: 1,              // 滚动条垂直偏移率
        isTaped: false,         // 鼠标光标是否按住滚动条
        isHover: false,         // 鼠标光标是否悬停在滚动区
        isShow: !this.autohide, // 是否显示滚动条
      }
    },
    mounted() {
      this.$ref__box = this.$refs.ref__box
      this.$ref__wrap = this.$refs.ref__wrap
      this.$ref__barY = this.$refs.ref__barY
      this.$ref__barX = this.$refs.ref__barX
      this.$nextTick(this.updated)
    },
    // ...
    methods: {
      // 鼠标移入
      handleMouseEnter() {
        this.isHover = true
        this.isShow = true
        this.updated()
      },
 
      // 鼠标移出
      handleMouseLeave() {
        this.isHover = false
        this.isShow = false
      },
 
      // 拖动滚动条
      handleDragThumb(e, index) {
        let _this = this
        this.isTaped = true
        let c = {}
        
        domUtils.isIE() ? (e.returnValue = false, e.cancelBubble = true) : (e.stopPropagation(), e.preventDefault())
        document.onselectstart = () => false
 
        if(index == 0) {
          c.dragY = true
          c.clientY = e.clientY
        }else {
          c.dragX = true
          c.clientX = e.clientX
        }
 
        domUtils.on(document, 'mousemove', function(evt) {
          if(_this.isTaped) {
            if(c.dragY) {
              _this.$ref__wrap.scrollTop += (evt.clientY - c.clientY) * _this.ratioY
              _this.$ref__barY.style.transform = `translateY(${_this.$ref__wrap.scrollTop / _this.ratioY}px)`
              c.clientY = evt.clientY
            }
            if(c.dragX) {
              _this.$ref__wrap.scrollLeft += (evt.clientX - c.clientX) * _this.ratioX
              _this.$ref__barX.style.transform = `translateX(${_this.$ref__wrap.scrollLeft / _this.ratioX}px)`
              c.clientX = evt.clientX
            }
          }
        })
        domUtils.on(document, 'mouseup', function() {
          _this.isTaped = false
          
          document.onmouseup = null;
          document.onselectstart = null
        })
      },
 
      // 滚动槽
      handleClickTrack(e, index) {
        console.log(index)
      },
 
      // 更新滚动
      updated() {
        if(this.native) return
 
        // 垂直滚动条
        if(this.$ref__wrap.scrollHeight > this.$ref__wrap.offsetHeight) {
          this.barHeight = this.$ref__box.offsetHeight **2 / this.$ref__wrap.scrollHeight
          this.ratioY = (this.$ref__wrap.scrollHeight - this.$ref__box.offsetHeight) / (this.$ref__box.offsetHeight - this.barHeight)
          this.$ref__barY.style.transform = `translateY(${this.$ref__wrap.scrollTop / this.ratioY}px)`
        }else {
          this.barHeight = 0
          this.$ref__barY.style.transform = ''
          this.$ref__wrap.style.marginRight = ''
        }
 
        // 水平滚动条
        ...
      },
 
      handleResize() {
        // 更新滚动条状态
      },
 
      // ...
    }
  }
</script>

vue.js中如何监听DOM尺寸变化?非常简单,只需自定义一个指令用来监听滚动条区域的状态变化(宽度/高度)。

// 监听元素/DOM尺寸变化
directives: {
    'resize': {
        bind: function(el, binding) {
            let width = '', height = '';
            function get() {
            const elStyle = el.currentStyle ? el.currentStyle : document.defaultView.getComputedStyle(el, null);
            if (width !== elStyle.width || height !== elStyle.height) {
                binding.value({width, height});
            }
            width = elStyle.width;
            height = elStyle.height;
            }
            el.__vueReize__ = setInterval(get, 16);
        },
        unbind: function(el) {
            clearInterval(el.__vueReize__);
        }
    }
},

image
image

<v-scroll @scroll="handleScroll">
    <img data-original="https://cn.vuejs.org/images/logo.png" style="height:180px;margin-right:10px;" />
    <br />
    <p><img data-original="https://cn.vuejs.org/images/logo.png" style="height:250px;" /></p>
    <p>这里是内容信息!这里是内容信息!这里是内容信息!这里是内容信息!这里是内容信息!这里是内容信息!</p>
</v-scroll>
 
// 监听滚动事件
handleScroll(e) {
    this.scrollTop = e.target.scrollTop
    // 判断滚动状态
    if(e.target.scrollTop == 0) {
        this.scrollStatus = '到达顶部'
    } else if(e.target.scrollTop + e.target.offsetHeight >= e.target.scrollHeight) {
        this.scrollStatus = '到达底部'
    }else {
        this.scrollStatus = '滚动中....'
    }
}

小伙伴们如果感兴趣也可以去捣鼓一下哈,如果有其它好的方法,欢迎交流讨论。
好了,基于vue.js开发模拟滚动条组件就介绍到这里。希望能喜欢~~✍✍

Vue+PC桌面端自定义弹窗组件VLayer

image.png

查看原文

赞 13 收藏 11 评论 2

xiaoyan2017 发布了文章 · 2020-11-11

基于vue pc端模态框组件|vue layer弹窗

介绍

vlayer 一款融合Alert|Dialog|Modal|Message|Notify|Popover|Toast|ActionSheet等众多功能的桌面端弹出框组件。在设计开发之初,参考借鉴了Layer弹层插件、antd及iView等组件库。


快速开始

在main.js中引入弹窗组件。

import VLayer from './components/vlayer';
Vue.use(VLayer);

提供了标签式函数式2种可供选择的调用方式。

<v-layer 
    v-model="isConfirm"
    title="标题内容"
    content="<div style='color:#06f;padding:15px;'>弹窗内容信息!</div>"
    xclose
    z-index="2002"
    lockScroll="false"
    resize
    dragOut
    :btns="[
        {text: '取消', click: () => isConfirm=false},
        {text: '确定', style: 'color:#f90;', click: handleFn},
    ]"
/>

由于vlayer挂载了全局函数,支持函数式写法。

let $el = this.$vlayer({
    title: '标题内容',
    content: '<div style='color:#06f;padding:15px;'>弹窗内容信息!</div>', 
    xclose: true,
    zIndex: 2002,
    lockScroll: false,
    resize: true,
    dragOut: true,
    btns: [
        {text: '取消', click: () => { $el.close(); }},
        {text: '确定', click: () => this.handleFn()},
    ]
});

效果预览

image
image
image
image
image
image
image
image
image
image

参数配置

vlayer支持多达30+种参数任意配置。

@@默认参数
v-model     当前组件是否显示
title       标题
content     内容(支持自定义插槽内容)
type        弹窗类型(toast | footer | actionsheet | android/ios | contextmenu | drawer | iframe | message/notify/popover)
layerStyle  自定义弹窗样式
icon        toast图标(loading | success | fail)
shade       是否显示遮罩层
shadeClose  是否点击遮罩时关闭弹窗
lockScroll  是否弹窗出现时将 body 滚动锁定
opacity     遮罩层透明度
xclose      是否显示关闭图标
xposition   关闭图标位置(left | right | top | bottom)
xcolor      关闭图标颜色
anim        弹窗动画(scaleIn | fadeIn | footer | fadeInUp | fadeInDown | fadeInLeft | fadeInRight)
position    弹出位置(auto | ['100px','50px'] | t | r | b | l | lt | rt | lb | rb)
drawer      抽屉弹窗(top | right | bottom | left)
follow      跟随元素定位弹窗(支持元素.kk #kk 或 [e.clientX, e.clientY])
time        弹窗自动关闭秒数(1、2、3)
zIndex      弹窗层叠(默认8080)
topmost     置顶当前窗口(默认false)
area        弹窗宽高(默认auto)设置宽度area: '300px' 设置高度area:['', '200px'] 设置宽高area:['350px', '150px']
maxWidth    弹窗最大宽度(只有当area:'auto'时,maxWidth的设定才有效)
maximize    是否显示最大化按钮(默认false)
fullscreen  全屏弹窗(默认false)
fixed       弹窗是否固定
drag        拖拽元素(可定义选择器drag:'.xxx' | 禁止拖拽drag:false)
dragOut     是否允许拖拽到窗口外(默认false)
resize      是否允许拉伸尺寸(默认false)
btns        弹窗按钮(参数:text|style|disabled|click)
------------------------------------------
@@组件式事件
open        打开弹出层时触发(@open="xxx")
close       关闭弹出层时触发(@close="xxx")
------------------------------------------
@@函数式事件
onOpen      打开弹窗回调
onClose     关闭弹窗回调

vlayer弹窗模板

<template>
  <div v-show="opened" class="vui__layer" :class="{'vui__layer-closed': closeCls}" :id="vlayerId">
    <div v-if="JSON.parse(shade)" class="vlayer__overlay" @click="shadeClicked" :style="{opacity}"></div>
    <div class="vlayer__wrap" :class="['anim-'+anim, type&&'popui__'+type, drawer&&'popui__drawer-'+drawer, xclose&&'vlayer-closable', tipArrow]" :style="layerStyle">
      <div v-if="title" class="vlayer__wrap-tit" v-html="title"></div>
      <div v-if="type=='toast'&&icon" class="vlayer__toast-icon" :class="['vlayer__toast-'+icon]" v-html="toastIcon[icon]"></div>
      <div class="vlayer__wrap-cntbox">
        <template v-if="$slots.content">
          <div class="vlayer__wrap-cnt"><slot name="content" /></div>
        </template>
        <template v-else>
          <template v-if="content">
            <iframe v-if="type=='iframe'" scrolling="auto" allowtransparency="true" frameborder="0" :data-original="content"></iframe>
            <!-- message|notify|popover -->
            <div v-else-if="type=='message' || type=='notify' || type=='popover'" class="vlayer__wrap-cnt">
              <i v-if="icon" class="vlayer-msg__icon" :class="icon" v-html="messageIcon[icon]"></i>
              <div class="vlayer-msg__group"><div v-if="title" class="vlayer-msg__title" v-html="title"></div><div class="vlayer-msg__content" v-html="content"></div></div>
            </div><div v-else class="vlayer__wrap-cnt" v-html="content"></div>
          </template>
        </template>
        <slot />
      </div>
      <div v-if="btns" class="vlayer__wrap-btns">
        <span v-for="(btn,index) in btns" :key="index" class="btn" :class="{'btn-disabled': btn.disabled}" :style="btn.style" v-html="btn.text"></span>
      </div>
      <span v-if="xclose" class="vlayer__xclose" :class="!maximize&&xposition" :style="{'color': xcolor}" @click="close"></span>
      <span v-if="maximize" class="vlayer__maximize"></span>
      <span v-if="resize" class="vlayer__resize"></span>
    </div>
    <!-- 修复拖拽卡顿 -->
    <div class="vlayer__dragfix"></div>
  </div>
</template>

下面贴出的是js主要实现功能。

/**
 * @Desc     vue自定义对话框组件VLayer
 * @Time     andy by 2020-10-28
 * @About    Q:282310962  wx:xy190310
 */
<script>
  import domUtils from './utils/dom'
  let $index = 0, $lockCount = 0, $timer = {};
  let ie = !!window.ActiveXObject || "ActiveXObject" in window;
  export default {
    props: {
      // ...
    },
    data() {
      return {
        opened: false,
        closeCls: '',
        toastIcon: {
          // ...
        },
        messageIcon: {
          // ...
        },
        vlayerOpts: {},
        tipArrow: null,
      }
    },
    mounted() {
      window.addEventListener('resize', () => {
        this.autopos();
      })
    },
    computed: {
      vlayerId() {
        return this.id ? this.id : `vlayer-${domUtils.generateId()}`;
      }
    },
    watch: {
      value(val) {
        const type = val ? 'open' : 'close';
        this[type]();
      },
    },
    methods: {
      // 打开弹窗
      open() {
        if(this.opened) return;
        this.opened = true;
        this.$emit('open');
        typeof this.onOpen === 'function' && this.onOpen();

        const dom = this.$el;
        this.$nextTick(() => {
          document.body.appendChild(dom);

          this.auto();
        })

        this.callback();
      },
      // 关闭弹窗
      close() {
        if(!this.opened) return;

        let dom = this.$el;
        let vlayero = dom.querySelector('.vlayer__wrap');
        let ocnt = dom.querySelector('.vlayer__wrap-cntbox');
        let omax = dom.querySelector('.vlayer__maximize');

        this.closeCls = true;
        setTimeout(() => {
          this.opened = false;
          this.closeCls = false;
          if(this.vlayerOpts.lockScroll) {
            $lockCount--;
            if(!$lockCount) {
              document.body.style.paddingRight = '';
              document.body.classList.remove('nt-overflow-hidden');
            }
          }
          if(this.time) {
            $index--;
          }

          this.$emit('input', false);
          this.$emit('close');
          typeof this.onClose === 'function' && this.onClose();
        }, 200);
      },

      // 弹窗位置
      auto() {
        // ...

        this.autopos();

        // 全屏弹窗
        if(this.fullscreen) {
          this.full();
        }

        // 弹窗拖动|缩放
        this.move();
      },

      autopos() {
        if(!this.opened) return;
        let oL, oT;
        let pos = this.position;
        let isFixed = JSON.parse(this.fixed);
        let dom = this.$el;
        let vlayero = dom.querySelector('.vlayer__wrap');

        let area = [domUtils.client('width'), domUtils.client('height'), vlayero.offsetWidth, vlayero.offsetHeight]

        oL = (area[0] - area[2]) / 2;
        oT = (area[1] - area[3]) / 2;

        if(this.follow) {
          this.offset();
        }else {
          typeof pos === 'object' ? (
            oL = parseFloat(pos[0]) || 0, oT = parseFloat(pos[1]) || 0
          ) : (
            // ...
          )

          vlayero.style.left = parseFloat(isFixed ? oL : domUtils.scroll('left') + oL) + 'px';
          vlayero.style.top = parseFloat(isFixed ? oT : domUtils.scroll('top') + oT) + 'px';
        }
      },

      // 元素跟随定位
      offset() {
        let oW, oH, pS;
        let dom = this.$el;
        let vlayero = dom.querySelector('.vlayer__wrap');

        oW = vlayero.offsetWidth;
        oH = vlayero.offsetHeight;
        pS = domUtils.getFollowRect(this.follow, oW, oH);
        this.tipArrow = pS[2];

        vlayero.style.left = pS[0] + 'px';
        vlayero.style.top = pS[1] + 'px';
      },

      // 最大化弹窗
      full() {
        let timer;
        let isFixed = JSON.parse(this.fixed);
        let dom = this.$el;
        let vlayero = dom.querySelector('.vlayer__wrap');
        let otit = dom.querySelector('.vlayer__wrap-tit');
        let ocnt = dom.querySelector('.vlayer__wrap-cntbox');
        let obtn = dom.querySelector('.vlayer__wrap-btns');
        let omax = dom.querySelector('.vlayer__maximize');

        let t = otit ? otit.offsetHeight : 0;
        let b = obtn ? obtn.offsetHeight : 0;

        let rect = [
          parseFloat(domUtils.getStyle(vlayero, 'left')), parseFloat(domUtils.getStyle(vlayero, 'top')),
          parseFloat(domUtils.getStyle(vlayero, 'width')), parseFloat(domUtils.getStyle(vlayero, 'height'))||vlayero.offsetHeight
        ]
        this.vlayerOpts.rect = rect;

        clearTimeout(timer);
        timer = setTimeout(() => {
          vlayero.style.left = isFixed ? 0 : domUtils.scroll('left') + 'px';
          vlayero.style.top = isFixed ? 0 : domUtils.scroll('top') + 'px';
          vlayero.style.width = domUtils.client('width') + 'px';
          vlayero.style.height = domUtils.client('height') + 'px';
          ocnt.style.height = domUtils.client('height') - t - b + 'px';
        }, 16);
      },

      // 恢复弹窗
      restore() {
        let dom = this.$el;
        let vlayero = dom.querySelector('.vlayer__wrap');
        let otit = dom.querySelector('.vlayer__wrap-tit');
        let ocnt = dom.querySelector('.vlayer__wrap-cntbox');
        let obtn = dom.querySelector('.vlayer__wrap-btns');
        let omax = dom.querySelector('.vlayer__maximize');

        let t = otit ? otit.offsetHeight : 0;
        let b = obtn ? obtn.offsetHeight : 0;

        vlayero.style.left = parseFloat(this.vlayerOpts.rect[0]) + 'px';
        vlayero.style.top = parseFloat(this.vlayerOpts.rect[1]) + 'px';
        vlayero.style.width = parseFloat(this.vlayerOpts.rect[2]) + 'px';
        vlayero.style.height = parseFloat(this.vlayerOpts.rect[3]) + 'px';
        ocnt.style.height = parseFloat(this.vlayerOpts.rect[3]) - t - b + 'px';
      },

      // 拖动|缩放弹窗
      move() {
        // ...
      },

      // 事件处理
      callback() {
        // 倒计时关闭
        if(this.time) {
          $index++;
          // 防止重复点击
          if($timer[$index] !== null) clearTimeout($timer[$index])
          $timer[$index] = setTimeout(() => {
            this.close();
          }, parseInt(this.time) * 1000);
        }
      },
      // 点击最大化按钮
      maximizeClicked(e) {
        let o = e.target;
        if(o.classList.contains('maximized')) {
          // 恢复
          this.restore();
        } else {
          // 最大化
          this.full();
        }
      },
      // 点击遮罩层
      shadeClicked() {
        if(JSON.parse(this.shadeClose)) {
          this.close();
        }
      },
      // 按钮事件
      btnClicked(e, index) {
        let btn = this.btns[index];
        if(!btn.disabled) {
          typeof btn.click === 'function' && btn.click(e)
        }
      },
    },
  }
</script>

默认是标题区可以拖拽的,当然也可以自定义拖拽元素,只需设置 drag: '#xxx'  或者设置drag: false 来禁止弹窗拖拽功能。

当设置 dragOut: true 窗体可以拖拽到浏览器外部。

当使用popover弹窗,需要传入follow: '#xxxx' 定位元素。

let $follow = this.$vlayer.popover({
    follow: '#popover',
    icon: 'warning',
    content: '这是一段内容确定删除吗?', 
    time: null, 
    xclose: false,
    btns: [
        {text: 'no', click: () => { $follow.close(); }},
        {text: 'yes', click: () => this.handleXXX()},
    ],
    onClose: function() {
        this.$vlayer.message({content: 'success closed', icon: 'success'})
    }
});

另外还支持自定义弹窗显示位置,默认是auto居中['150px','100px'] | t | r | b | l | lt | rt | lb | rb

<!-- 自定义弹窗位置 -->
<v-layer v-model="showPosition" xclose maximize drag=".dragImg" :position="rb">
    <img class="dragImg" data-original="xxx.jpg" />
</v-layer>

当需要一打开弹窗就全屏,只需配置fullscreen:true即可。
image

ok,基于vue.js开发桌面端弹框就分享到这里。希望大家能喜欢~~ ✍💪

image

查看原文

赞 6 收藏 4 评论 0

xiaoyan2017 发布了文章 · 2020-10-16

基于Nuxt.js+Vue聊天实例|nuxt仿微信/探探聊天界面

1、项目简介

Nuxt.js是目前比较热门的服务端渲染SSR框架。凭借其更好的SEO、更快的内容到达时间(*首屏渲染速度快*) 加之基于Vue.js技术开发,更易于上手,获得了很多技术开发者的青睐。
nuxt_chatroom项目是基于nuxt+vue+vuex+vant等技术开发的仿微信|Tinder界面聊天实例。实现了卡片式翻动、消息发送/表情gif、图片/视频预览、红包/朋友圈等功能。

效果片段
image
啧啧,效果还可以吧,下面就来讲解下实现过程。

2、技术框架

  • 使用技术:nuxt.js+vue.js+vuex
  • UI组件库:vant-ui (有赞移动端vue.js组件库)
  • 字体图标:阿里iconfont图标库
  • 弹窗组件:vpopup(基于vue自定义弹框)
  • 本地存储:cookie-universal-nuxt: ^2.1.4

image
image
image
image
image
image
image
image
image
image
image
image
image
image
image
image
image
image

大家如果对Nuxt.js不熟悉的话,可以先去官网看看资料。其实只要你会vue,上手就非常简单的。
image.png
https://zh.nuxtjs.org/
https://github.com/nuxt/nuxt.js

3、项目目录结构

image.png
想了解更多Nuxt.js目录结构及使用说明,可以去参看下面链接。
https://zh.nuxtjs.org/guide/d...

4、关于自定义组件

项目中 顶部Navbar、Tabbar及弹窗 均是自定义组件实现的,这里不作过多讲解,感兴趣的可以去看看下面的分享文章。
image.png
image.png
image
Nuxt/Vue仿咸鱼Tabbar凸起效果|vue自定义导航栏
Vue自定义弹框组件|仿android/ios弹窗效果

5、仿Tinder|探探卡牌翻动

遇见页面原型参考了社交App探探的卡片滑动效果。整体分为 顶部导航、滑动区、底部标签栏 等三个部分。
image
这里不过多介绍,感兴趣的可以去看下面的这篇分享文章。
Vue|Nuxt.js仿探探卡片式左右拖拽|vue仿Tinder

6、nuxt默认布局

nuxt项目中,layouts目录下的default.vue页面为默认的布局页面。

<!-- 布局模板 -->
<template>
  <div class="nuxt__container flexbox flex-col">
    <header-bar />
    <div class="nuxt__scrollview scrolling flex1"><nuxt /></div>
    <tab-bar />
  </div>
</template>

注意:在nuxt项目中是通过<nuxt />来显示主体内容的,而vue中则是使用<router-view />

nuxt.config.js配置文件
nuxt.js的默认配置文件,可以配置 meta、路由信息、css/js及插件引入、webpack等配置。
https://zh.nuxtjs.org/guide/configuration/

export default {
  // 端口配置(可选)
  server: {
    port: 3000,
    host: '192.168.122.100'
  },
  /*
  ** 页面头部meta信息配置
  */
  head: {
    title: process.env.npm_package_name || '',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1, user-scalable=no' },
      { hid: 'keywords', name: 'keywords', content: 'Vue.js | Nuxt.js | Nuxt仿微信'},
      { hid: 'description', name: 'description', content: process.env.npm_package_description || '' }
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
      { rel: 'stylesheet', href: '/js/wcPop/skin/wcPop.css' },
    ],
    script: [
      { src: '/js/fontSize.js' },
      { src: '/js/wcPop/wcPop.js' },
    ]
  },
  /*
  ** 全局css配置
  */
  css: [
    '~/assets/css/reset.css',
    '~/assets/css/layout.css',
    '~/assets/fonts/iconfont.css',
  ],
  /*
  ** 全局插件列表
  */
  plugins: [
    '~/plugins/vue-global.js',
    // 通过这种方式引入本地js也可以(需设置ssr:false)
    // {src: '~/assets/js/fontSize.js', ssr: false}
  ],
  // ...
}

nuxt.config.js中的meta是全局配置的,如果单独页面配置,参考如下:

<script>
export default {
    // 配置页面 meta 信息
    head() {
        return {
            title: '这里是标题信息 - 标题信息',
            meta: [
                {name:'keywords',hid: 'keywords',content: '关键字1 | 关键字2 | 关键字3'},
                {name:'description',hid:'description',content: '描述1 | 描述2 | 描述3'}
            ]
        }
    },
    // 自定义布局页面
    layout: 'xxx.vue',
    // 中间件处理
    middleware: 'auth',
    // 异步处理
    async asyncData({app, params, query, store}) {
        let uid = params.uid
        let cid = query.cid
        return {
            uid: uid,
            cid: cid,
        }
    },
    // ...
}
</script>

7、Nuxt聊天模块

一开始考虑聊天模块的编辑框使用inputtextarea实现。可是文本框中只能插入emoj字符 (:32 :微笑:),不能插入emoj图片表情,不是自己想要的效果。
image
如上图:于是就使用了div的可编辑功能contenteditable来插入图文内容。

<template>
    <div
        ref="editor"
        class="editor"
        contentEditable="true"
        v-html="editorText"
        @click="handleClick"
        @input="handleInput"
        @focus="handleFocus"
        @blur="handleBlur"
        style="user-select:text;-webkit-user-select:text;">
    </div>
</template>

经过一系列处理,编辑器支持多行文本输入、光标处插入emoj表情等功能。

如下图:在vue.js项目中如何获取上传视频的第一帧作为封面图?
image.png
网上的很多的解决方案,会使截图出现黑屏情况。后来经过反复调试,终于实现了这个效果。

let file = this.$refs.chooseVideo.files[0]
if(!file) return
let size = Math.floor(file.size / 1024)
if(size > 3*1024) {
    alert('请选择3MB以内的视频')
    return false
}
// 获取视频地址
let videoUrl
if(window.createObjectURL != undefined) {
    videoUrl = window.createObjectURL(file)
} else if (window.URL != undefined) {
    videoUrl = window.URL.createObjectURL(file)
} else if (window.webkitURL != undefined) {
    videoUrl = window.webkitURL.createObjectURL(file)
}

let $video = document.createElement('video')
$video.src = videoUrl
// 防止移动端封面黑屏或透明白屏
$video.play()
$video.muted = true
$video.addEventListener('timeupdate', () => {
    if($video.currentTime > .1) {
        $video.pause()
    }
})

// 截取视频第一帧作为封面
$video.addEventListener('loadeddata', function() {
    setTimeout(() => {
        var canvas = document.createElement('canvas')
        canvas.width = $video.videoWidth * .8
        canvas.height = $video.videoHeight * .8
        canvas.getContext('2d').drawImage($video, 0, 0, canvas.width, canvas.height)
        let videoThumb = canvas.toDataURL('image/png')
        console.log(videoThumb)
    }, 16);
})

大家有兴趣的话,可以自己去弄一弄试试哈。如果有其它好的方法,欢迎交流讨论!

OK,基于Nuxt.js仿微信聊天室项目就分享到这里。希望对大家有些帮助!✍💪

image

最后附上个Flutter项目实例
flutter+dart聊天室|flutter仿微信App聊天界面|flutter聊天实例
image

查看原文

赞 16 收藏 12 评论 2

xiaoyan2017 发布了文章 · 2020-10-12

Vue|Nuxt.js仿探探卡片式左右拖拽|vue仿Tinder

开场

技术宅男对探探/陌陌并不陌生,一款专注于陌生人的社交App。里面的左右滑动翻牌子效果更是让人眼前一亮,似乎有一种古时君王选妃子的感觉。让人玩的爱不释手。

一睹风采
image
哈哈,效果还行吧。下面就简单的讲解下具体的实现方法。

页面布局

页面整体分为 顶部Navbar、卡片区域、底部Tabbar 三个部分。
image.png

<!-- //翻一翻模板 -->
<template>
    <div>
        <!-- >>顶部 -->
        <header-bar :back="false" bgcolor="linear-gradient(to right, #00e0a1, #00a1ff)" fixed>
            <div slot="title"><i class="iconfont icon-like c-red"></i> <em class="ff-gg">遇见TA</em></div>
            <div slot="right" class="ml-30" @click="showFilter = true"><i class="iconfont icon-filter"></i></div>
        </header-bar>
 
        <!-- >>主页面 -->
        <div class="nuxt__scrollview scrolling flex1" ref="scrollview" style="background: linear-gradient(to right, #00e0a1, #00a1ff);">
            <div class="nt__flipcard">
                <div class="nt__stack-wrapper">
                    <flipcard ref="stack" :pages="stackList" @click="handleStackClicked"></flipcard>
                </div>
                <div class="nt__stack-control flexbox">
                    <button class="btn-ctrl prev" @click="handleStackPrev"><i class="iconfont icon-unlike "></i></button>
                    <button class="btn-ctrl next" @click="handleStackNext"><i class="iconfont icon-like "></i></button>
                </div>
            </div>
        </div>
 
        <!-- >>底部tabbar -->
        <tab-bar bgcolor="linear-gradient(to right, #00e0a1, #00a1ff)" color="#fff" />
    </div>
</template>

侧边弹出框

点击筛选,在侧边会出现弹窗。其中范围滑块、switch开关、Rate评分等组件则是使用Vant组件库。
image.png

侧边弹窗模板

<template>
    <!-- ... -->
    
    <!-- @@侧边栏弹框模板 -->
    <v-popup v-model="showFilter" position="left" xclose xposition="left" title="高级筛选与设置">
        <div class="flipcard-filter">
            <div class="item nuxt-cell">
                <label class="lbl">范围</label>
                <div class="flex1">
                    <van-slider v-model="distanceRange" bar-height="2px" button-size="12px" active-color="#00e0a1" min="1" @input="handleDistanceRange" />
                </div>
                <em class="val">{{distanceVal}}</em>
            </div>
            <div class="item nuxt-cell">
                <label class="lbl flex1">自动增加范围</label>
                <em class="val"><van-switch v-model="autoExpand" size="20px" active-color="#00e0a1" /></em>
            </div>
            <div class="item nuxt-cell">
                <label class="lbl flex1">性别</label>
                <em class="val">女生</em>
            </div>
            <div class="item nuxt-cell">
                <label class="lbl">好评度</label>
                <div class="flex1"><van-rate v-model="starVal" color="#00e0a1" icon="like" void-icon="like-o" @change="handleStar" /></div>
                <em class="val">{{starVal}}星</em>
            </div>
            <div class="item nuxt-cell">
                <label class="lbl flex1">优先在线用户</label>
                <em class="val"><van-switch v-model="firstOnline" size="20px" active-color="#00e0a1" /></em>
            </div>
            <div class="item nuxt-cell">
                <label class="lbl flex1">优先新用户</label>
                <em class="val"><van-switch v-model="firstNewUser" size="20px" active-color="#00e0a1" /></em>
            </div>
            <div class="item nuxt-cell mt-20">
                <div class="mt-30 nuxt__btn nuxt__btn-primary--gradient" style="height:38px;"><i class="iconfont icon-filter"></i> 更新</div>
            </div>
        </div>
    </v-popup>
</template>
 
<script>
    export default {
        // 用于配置应用默认的 meta 标签
        head() {
            return {
                title: `${this.title} - 翻一翻`,
                meta: [
                    {name:'keywords',hid: 'keywords',content:`${this.title} | 翻一翻 | 翻动卡片`},
                    {name:'description',hid:'description',content:`${this.title} | 仿探探卡片翻动`}
                ]
            }
        },
        middleware: 'auth',
        data () {
            return {
                title: 'Nuxt',
                showFilter: false,
                distanceRange: 1,
                distanceVal: '<1km',
                autoExpand: true,
                starVal: 5,
                firstOnline: false,
                firstNewUser: true,
                
                // ...
            }
        },
        methods: {
            /* @@左侧筛选函数 */
            // 范围选择
            handleDistanceRange(val) {
                if(val == 1) {
                    this.distanceVal = '<1km';
                } else if (val == 100) {
                    this.distanceVal = "100km+"
                }else {
                    this.distanceVal = val+'km';
                }
            },
            // 好评度
            handleStar(val) {
                this.starVal = val;
            },
            
            // ...
        },
    }
</script>

仿探探翻牌子

卡片区单独封装了一个组件flipcard,只需传入pages数据就可以。
<flipcard ref="stack" :pages="stackList"></flipcard>
image.png
在四周拖拽卡片会出现不同的斜切视角。

pages数据格式

module.exports = [
    {
        avatar: '/assets/img/avatar02.jpg',
        name: '放荡不羁爱自由',
        sex: 'female',
        age: 23,
        starsign: '天秤座',
        distance: '艺术/健身',
        photos: [...],
        sign: '交个朋友,非诚勿扰'
    },
    
    ...
]

flipcard组件模板

<template>
    <ul class="stack">
        <li class="stack-item" v-for="(item, index) in pages" :key="index" :style="[transformIndex(index),transform(index)]"
            @touchmove.stop.capture="touchmove"
            @touchstart.stop.capture="touchstart"
            @touchend.stop.capture="touchend($event, index)"
            @touchcancel.stop.capture="touchend($event, index)"
            @mousedown.stop.capture.prevent="touchstart"
            @mouseup.stop.capture.prevent="touchend($event, index)"
            @mousemove.stop.capture.prevent="touchmove"
            @mouseout.stop.capture.prevent="touchend($event, index)"
            @webkit-transition-end="onTransitionEnd(index)"
            @transitionend="onTransitionEnd(index)"
        >
            <img :data-original="item.avatar" />
            <div class="stack-info">
                <h2 class="name">{{item.name}}</h2>
                <p class="tags">
                    <span class="sex" :class="item.sex"><i class="iconfont" :class="'icon-'+item.sex"></i> {{item.age}}</span>
                    <span class="xz">{{item.starsign}}</span>
                </p>
                <p class="distance">{{item.distance}}</p>
            </div>
        </li>
    </ul>
</template>
/**
 * @Desc     Vue仿探探|Tinder卡片滑动FlipCard
 * @Time     andy by 2020-10-06
 * @About    Q:282310962  wx:xy190310
 */
<script>
    export default {
        props: {
            pages: {
                type: Array,
                default: {}
            }
        },
        data () {
            return {
                basicdata: {
                    start: {},
                    end: {}
                },
                temporaryData: {
                    isStackClick: true,
                    offsetY: '',
                    poswidth: 0,
                    posheight: 0,
                    lastPosWidth: '',
                    lastPosHeight: '',
                    lastZindex: '',
                    rotate: 0,
                    lastRotate: 0,
                    visible: 3,
                    tracking: false,
                    animation: false,
                    currentPage: 0,
                    opacity: 1,
                    lastOpacity: 0,
                    swipe: false,
                    zIndex: 10
                }
            }
        },
        computed: {
            // 划出面积比例
            offsetRatio () {
                let width = this.$el.offsetWidth
                let height = this.$el.offsetHeight
                let offsetWidth = width - Math.abs(this.temporaryData.poswidth)
                let offsetHeight = height - Math.abs(this.temporaryData.posheight)
                let ratio = 1 - (offsetWidth * offsetHeight) / (width * height) || 0
                return ratio > 1 ? 1 : ratio
            },
            // 划出宽度比例
            offsetWidthRatio () {
                let width = this.$el.offsetWidth
                let offsetWidth = width - Math.abs(this.temporaryData.poswidth)
                let ratio = 1 - offsetWidth / width || 0
                return ratio
            }
        },
        methods: {
            touchstart (e) {
                if (this.temporaryData.tracking) {
                    return
                }
                // 是否为touch
                if (e.type === 'touchstart') {
                    if (e.touches.length > 1) {
                        this.temporaryData.tracking = false
                        return
                    } else {
                        // 记录起始位置
                        this.basicdata.start.t = new Date().getTime()
                        this.basicdata.start.x = e.targetTouches[0].clientX
                        this.basicdata.start.y = e.targetTouches[0].clientY
                        this.basicdata.end.x = e.targetTouches[0].clientX
                        this.basicdata.end.y = e.targetTouches[0].clientY
                        // offsetY在touch事件中没有,只能自己计算
                        this.temporaryData.offsetY = e.targetTouches[0].pageY - this.$el.offsetParent.offsetTop
                    }
                // pc操作
                } else {
                    this.basicdata.start.t = new Date().getTime()
                    this.basicdata.start.x = e.clientX
                    this.basicdata.start.y = e.clientY
                    this.basicdata.end.x = e.clientX
                    this.basicdata.end.y = e.clientY
                    this.temporaryData.offsetY = e.offsetY
                }
                this.temporaryData.isStackClick = true
                this.temporaryData.tracking = true
                this.temporaryData.animation = false
            },
            touchmove (e) {
                this.temporaryData.isStackClick = false
                // 记录滑动位置
                if (this.temporaryData.tracking && !this.temporaryData.animation) {
                    if (e.type === 'touchmove') {
                        e.preventDefault()
                        this.basicdata.end.x = e.targetTouches[0].clientX
                        this.basicdata.end.y = e.targetTouches[0].clientY
                    } else {
                        e.preventDefault()
                        this.basicdata.end.x = e.clientX
                        this.basicdata.end.y = e.clientY
                    }
                    // 计算滑动值
                    this.temporaryData.poswidth = this.basicdata.end.x - this.basicdata.start.x
                    this.temporaryData.posheight = this.basicdata.end.y - this.basicdata.start.y
                    let rotateDirection = this.rotateDirection()
                    let angleRatio = this.angleRatio()
                    this.temporaryData.rotate = rotateDirection * this.offsetWidthRatio * 15 * angleRatio
                }
            },
            touchend (e, index) {
                if(this.temporaryData.isStackClick) {
                    this.$emit('click', index)
                    this.temporaryData.isStackClick = false
                }
                this.temporaryData.isStackClick = true
                this.temporaryData.tracking = false
                this.temporaryData.animation = true
                // 滑动结束,触发判断
                // 判断划出面积是否大于0.4
                if (this.offsetRatio >= 0.4) {
                    // 计算划出后最终位置
                    let ratio = Math.abs(this.temporaryData.posheight / this.temporaryData.poswidth)
                    this.temporaryData.poswidth = this.temporaryData.poswidth >= 0 ? this.temporaryData.poswidth + 200 : this.temporaryData.poswidth - 200
                    this.temporaryData.posheight = this.temporaryData.posheight >= 0 ? Math.abs(this.temporaryData.poswidth * ratio) : -Math.abs(this.temporaryData.poswidth * ratio)
                    this.temporaryData.opacity = 0
                    this.temporaryData.swipe = true
                    this.nextTick()
                    // 不满足条件则滑入
                } else {
                    this.temporaryData.poswidth = 0
                    this.temporaryData.posheight = 0
                    this.temporaryData.swipe = false
                    this.temporaryData.rotate = 0
                }
            },
            nextTick () {
                // 记录最终滑动距离
                this.temporaryData.lastPosWidth = this.temporaryData.poswidth
                this.temporaryData.lastPosHeight = this.temporaryData.posheight
                this.temporaryData.lastRotate = this.temporaryData.rotate
                this.temporaryData.lastZindex = 20
                // 循环currentPage
                this.temporaryData.currentPage = this.temporaryData.currentPage === this.pages.length - 1 ? 0 : this.temporaryData.currentPage + 1
                // currentPage切换,整体dom进行变化,把第一层滑动置最低
                this.$nextTick(() => {
                    this.temporaryData.poswidth = 0
                    this.temporaryData.posheight = 0
                    this.temporaryData.opacity = 1
                    this.temporaryData.rotate = 0
                })
            },
            onTransitionEnd (index) {
                let lastPage = this.temporaryData.currentPage === 0 ? this.pages.length - 1 : this.temporaryData.currentPage - 1
                // dom发生变化正在执行的动画滑动序列已经变为上一层
                if (this.temporaryData.swipe && index === lastPage) {
                    this.temporaryData.animation = true
                    this.temporaryData.lastPosWidth = 0
                    this.temporaryData.lastPosHeight = 0
                    this.temporaryData.lastOpacity = 0
                    this.temporaryData.lastRotate = 0
                    this.temporaryData.swipe = false
                    this.temporaryData.lastZindex = -1
                }
            },
            prev () {
                this.temporaryData.tracking = false
                this.temporaryData.animation = true
                // 计算划出后最终位置
                let width = this.$el.offsetWidth
                this.temporaryData.poswidth = -width
                this.temporaryData.posheight = 0
                this.temporaryData.opacity = 0
                this.temporaryData.rotate = '-3'
                this.temporaryData.swipe = true
                this.nextTick()
            },
            next () {
                this.temporaryData.tracking = false
                this.temporaryData.animation = true
                // 计算划出后最终位置
                let width = this.$el.offsetWidth
                this.temporaryData.poswidth = width
                this.temporaryData.posheight = 0
                this.temporaryData.opacity = 0
                this.temporaryData.rotate = '3'
                this.temporaryData.swipe = true
                this.nextTick()
            },
            rotateDirection () {
                if (this.temporaryData.poswidth <= 0) {
                    return -1
                } else {
                    return 1
                }
            },
            angleRatio () {
                let height = this.$el.offsetHeight
                let offsetY = this.temporaryData.offsetY
                let ratio = -1 * (2 * offsetY / height - 1)
                return ratio || 0
            },
            inStack (index, currentPage) {
                let stack = []
                let visible = this.temporaryData.visible
                let length = this.pages.length
                for (let i = 0; i < visible; i++) {
                    if (currentPage + i < length) {
                        stack.push(currentPage + i)
                    } else {
                        stack.push(currentPage + i - length)
                    }
                }
                return stack.indexOf(index) >= 0
            },
            // 非首页样式切换
            transform (index) {
                let currentPage = this.temporaryData.currentPage
                let length = this.pages.length
                let lastPage = currentPage === 0 ? this.pages.length - 1 : currentPage - 1
                let style = {}
                let visible = this.temporaryData.visible
                if (index === this.temporaryData.currentPage) {
                    return
                }
                if (this.inStack(index, currentPage)) {
                    let perIndex = index - currentPage > 0 ? index - currentPage : index - currentPage + length
                    style['opacity'] = '1'
                    style['transform'] = 'translate3D(0,0,' + -1 * 60 * (perIndex - this.offsetRatio) + 'px' + ')'
                    style['zIndex'] = visible - perIndex
                    if (!this.temporaryData.tracking) {
                        style['transitionTimingFunction'] = 'ease'
                        style['transitionDuration'] = 300 + 'ms'
                    }
                } else if (index === lastPage) {
                    style['transform'] = 'translate3D(' + this.temporaryData.lastPosWidth + 'px' + ',' + this.temporaryData.lastPosHeight + 'px' + ',0px) ' + 'rotate(' + this.temporaryData.lastRotate + 'deg)'
                    style['opacity'] = this.temporaryData.lastOpacity
                    style['zIndex'] = this.temporaryData.lastZindex
                    style['transitionTimingFunction'] = 'ease'
                    style['transitionDuration'] = 300 + 'ms'
                } else {
                    style['zIndex'] = '-1'
                    style['transform'] = 'translate3D(0,0,' + -1 * visible * 60 + 'px' + ')'
                }
                return style
            },
            // 首页样式切换
            transformIndex (index) {
                if (index === this.temporaryData.currentPage) {
                    let style = {}
                    style['transform'] = 'translate3D(' + this.temporaryData.poswidth + 'px' + ',' + this.temporaryData.posheight + 'px' + ',0px) ' + 'rotate(' + this.temporaryData.rotate + 'deg)'
                    style['opacity'] = this.temporaryData.opacity
                    style['zIndex'] = 10
                    if (this.temporaryData.animation) {
                        style['transitionTimingFunction'] = 'ease'
                        style['transitionDuration'] = (this.temporaryData.animation ? 300 : 0) + 'ms'
                    }
                    return style
                }
            },
        }
    }
</script>

组件支持touchmouse事件,在移动端和PC端均可滑动。

image.png

另外,点击卡片跳转到卡片详细页面。

好了,基于Vue实现探探卡片效果就分享到这里。希望能喜欢~~ ✍

image

查看原文

赞 3 收藏 2 评论 2

认证与成就

  • 获得 173 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2019-07-20
个人主页被 3.6k 人浏览