网络日志

H5实现支付密码输入框(vue3)

微信、支付宝支付密码时的密码输入框大家都很熟悉,它由6个小格子组成,输入一个密码后会自动跳转到另一个格子,密码输入完成后就可以提交数据,在web并没有原生的这种输入框,要想使用这种输入框需要自己去实现。

最近的一个项目中需要使用这种输入框,由于项目比较小,因此就没有用其他的UI库,于是就自己造了一个密码输入框的轮子,效果如下图:

安卓设备:

iOS设备:

在ios上有一个小小的问题:输入框获得焦点后无法自动弹窗软键盘,有大神知道解决方法的话请评论区留言告知!!

1、DOM结构

dom结构这块比较简单,主要由:

  1. 隐藏的input 主要作用是:调起系统软键盘以及输入内容
  2. 6个小格子
  3. 虚拟光标

3部分组成。

<template>
  <div class="bs-password-input" ref="passwordInputRef">
    <input
      ref="realInput"
      type="number"
      inputmode="numeric"
      class="hidden-input"
      @input="onInput"
      @blur="blur">
    <ul
      class="bs-password-input-security"
      :class="{
        'has-gap': hasGap
      }"
      @click="focus">
      <li
        class="bs-password-input-item"
        :class="{
          'is-focus': focusInputIndex === index
        }"
        v-for="(pwd, index) in passwords"
        :key="index">
        <i v-if="mask && (pwd !== ' ')" class="password-input-dot"></i>
        <template v-if="!mask">{{ pwd }}</template>
        <div
          v-if="showInputCursor"
          class="bs-password-input-cursor"></div>
      </li>
    </ul>
    <div class="bs-password-input-info" v-if="info">{{ info }}</div>
  </div>
</template>

2、显示密码格子

界面上的密码格子数量主要通过父组件传递的密码及密码长度来计算

let passwords = computed(function () {
    let value = props.modelValue;
    if (typeof value !== 'string' && typeof value !== 'number') {
      value = '';
    } else {
      value = value + '';
    }

    // console.log('value', value);
    let resultArr = value.split('');
    let len = props.length; // 密码长度限制,默认为6位
    let diff = value.length - props.length;
    if (diff > 0) {
      resultArr = value.substr(0, len).split('');
    } else if (diff < 0) {
      diff = Math.abs(diff);
      // 如果传递的modelValue长度小于密码长度,则填补补空格,以达到界面上密码格子数量等于密码长度
      while (diff > 0) {
        resultArr.push(' ');
        diff--;
      }
    }

    return resultArr;
  });

3、计算获得焦点的格子的索引

当在输入时、删除时、用户点击格子时都需要知道当前哪个格子应该获得焦点,格子获得焦点后虚拟光标才会显示出来。如果calcFocusInputIndex()执行后的值为-1则说明密码输入完成,此时没人任何格子需要获得焦点

let calcFocusInputIndex = function () {
  let pwdVal = passwords.value;
  let index = -1;
  let realPwdVal = trim(pwdVal.join(''));
  console.log('realPwdVal', realPwdVal, realPwdVal.length, pwdVal);
  for (let i = 0, len = pwdVal.length; i < len; i++) {
    // pwdVal[i]为空格表示该格子并没有真正的值
    if (pwdVal[i] === ' ' && realPwdVal.length !== props.length) {
      index = i;
      break;
    }
  }
  console.log('index', index);
  return index;
};

4、监听输入

在用户输入密码时如果输入的不是数字,则需清空input里的内容当用户输入的密码长度等于props.length时立即完成输入

let onInput = function (evt) {
  let inputValue = evt.target.value;
  if (inputValue && !numberReg.test(inputValue)) { // 如果输入的不是数字则清空输入框
    evt.target.value = '';
    return;
  }
  console.log('输入的字符为:', inputValue);
  let password = passwords.value.join('');
  password = trim(password);
  password += inputValue;
  evt.target.value = '';
  ctx.emit('update:modelValue', password);
  if (password.length == props.length) {
    ctx.emit('complete', password);
  }
  // 隐藏输入框焦点
  nextTick(function () {
    let inputIndex = calcFocusInputIndex();
    if (inputIndex == -1) {
      blur();
    } else {
      focusInputIndex.value = inputIndex;
    }
  });
  console.log('更新modelValue', password);
};

5、隐藏掉input的光标

由于格子本身就有虚拟光标,那么此时input的光标就不需要再显示出来了。另外,就算没有虚拟光标也很难将input的光标准确的定位到每一个格子中间。

隐藏掉input光标主要靠3个技巧:

  1. 用绝对定位来隐藏input,而不是通过display: none;隐藏
  2. 字体颜色设为透明,字体阴影设为0
  3. text-indent 设置一个稍微大点的负值(主要解决ios设备中光标隐藏不了问题)
.hidden-input{
  position: absolute;
  top: 5px;
  z-index: 1;
  /* 隐藏光标 start */
  color: transparent;
  text-shadow: 0 0 0 #000;
  /* 隐藏光标 end */

  /* 隐藏ios设备光标 start */
  text-indent: -999em;
  margin-left: -40%;
  /* 隐藏ios设备光标 end */
}

6、完整代码

PasswordInput.vue

<template>
  <div class="bs-password-input" ref="passwordInputRef">
    <input
      ref="realInput"
      type="number"
      inputmode="numeric"
      class="hidden-input"
      @input="onInput"
      @blur="blur">
    <ul
      class="bs-password-input-security"
      :class="{
        'has-gap': hasGap
      }"
      @click="focus">
      <li
        class="bs-password-input-item"
        :class="{
          'is-focus': focusInputIndex === index
        }"
        v-for="(pwd, index) in passwords"
        :key="index">
        <i v-if="mask && (pwd !== ' ')" class="password-input-dot"></i>
        <template v-if="!mask">{{ pwd }}</template>
        <div
          v-if="showInputCursor"
          class="bs-password-input-cursor"></div>
      </li>
    </ul>
    <div class="bs-password-input-info" v-if="info">{{ info }}</div>
  </div>
</template>

<script>
import {
  ref,
  computed,
  onMounted,
  onUnmounted,
  nextTick
} from 'vue';

const trim = function (str) {
  if (typeof str !== 'string' || str.length === 0) {
    return str;
  }
  str += '';
  // 清除字符串两端空格,包含换行符、制表符
  return str.replace(/(^[\s\n\t]+|[\s\n\t]+$)/g, '');
}

export default {
  name: "PasswordInput",
  props: {
    modelValue: { // 密码值
      type: [String, Number],
      default: ''
    },
    hasGap: { // 是否有间隙
      type: Boolean,
      default: false
    },
    mask: { // 是否隐藏密码内容
      type: Boolean,
      default: true
    },
    length: { // 密码最大长度
      type: Number,
      default: 6
    },
    info: { // 输入框下方文字提示
      type: String,
      default: ''
    }
  },
  setup (props, ctx) {
    let passwordInputRef = ref(null);
    let realInput = ref(null);

    let passwords = computed(function () {
      let value = props.modelValue;
      if (typeof value !== 'string' && typeof value !== 'number') {
        value = '';
      } else {
        value = value + '';
      }

      // console.log('value', value);
      let resultArr = value.split('');
      let len = props.length;
      let diff = value.length - props.length;
      if (diff > 0) {
        resultArr = value.substr(0, len).split('');
      } else if (diff < 0) {
        diff = Math.abs(diff);
        while (diff > 0) {
          resultArr.push(' ');
          diff--;
        }
      }

      return resultArr;
    });

    // 计算获得焦点的虚拟输入框的索引
    let calcFocusInputIndex = function () {
      let pwdVal = passwords.value;
      let index = -1;
      let realPwdVal = trim(pwdVal.join(''));
      console.log('realPwdVal', realPwdVal, realPwdVal.length, pwdVal);
      for (let i = 0, len = pwdVal.length; i < len; i++) {
        if (pwdVal[i] === ' ' && realPwdVal.length !== props.length) {
          index = i;
          break;
        }
      }
      console.log('index', index);
      return index;
    };

    let nativeInputFocus = ref(false);
    let showInputCursor = ref(false);
    let focusInputIndex = ref(null);
    let focus = function () {
      let index = calcFocusInputIndex();
      if (index > -1) {
        realInput.value.focus();
        nativeInputFocus.value = true;
        showInputCursor.value = true;
        focusInputIndex.value = index;
      } else {
        realInput.value.focus();
        nativeInputFocus.value = true;
      }
    };
    let blur = function () {
      showInputCursor.value = false;
      focusInputIndex.value = null;
      realInput.value.blur();
      realInput.value.value = '';
      nativeInputFocus.value = false;
    };

    let numberReg = /^\d+$/;
    let onInput = function (evt) {
      let inputValue = evt.target.value;
      if (inputValue && !numberReg.test(inputValue)) { // 如果输入的不是数字则清空输入框
        evt.target.value = '';
        return;
      }
      console.log('输入的字符为:', inputValue);
      let password = passwords.value.join('');
      password = trim(password);
      password += inputValue;
      evt.target.value = '';
      ctx.emit('update:modelValue', password);
      if (password.length == props.length) {
        ctx.emit('complete', password);
      }
      // 隐藏输入框焦点
      nextTick(function () {
        let inputIndex = calcFocusInputIndex();
        if (inputIndex == -1) {
          blur();
        } else {
          focusInputIndex.value = inputIndex;
        }
      });
      console.log('更新modelValue', password);
    };

    let keydownEvent = function (evt) {
      let keyCode = evt.keyCode;
      console.log('keyCode', keyCode);
      if (!nativeInputFocus.value) {
        console.log('原生输入框未获得焦点');
        return;
      }
      if (keyCode == 8) { // 删除键
        let password = passwords.value.join('');
        password = trim(password);
        if (password.length == 0) {
          return;
        }
        password = password.substr(0, password.length - 1);
        console.log('new password', password);
        ctx.emit('update:modelValue', password);
        // 隐藏输入框焦点
        nextTick(function () {
          let inputIndex = calcFocusInputIndex();
          if (inputIndex == -1) {
            blur();
          } else {
            focusInputIndex.value = inputIndex;
            focus();
          }
        });
      }
    };

    onMounted(function () {
      document.addEventListener('keydown', keydownEvent, false);
    });

    onUnmounted(function () {
      document.removeEventListener('keydown', keydownEvent, false);
    });

    return {
      realInput,
      passwordInputRef,
      passwords,
      showInputCursor,
      focusInputIndex,
      blur,
      focus,
      onInput
    };
  }
};
</script>

<style lang="less">
@import "password-input";
</style>

password-input.less

.bs-password-input{
  position: relative;
  overflow: hidden;
  .hidden-input{
    position: absolute;
    top: 5px;
    z-index: 1;
    /* 隐藏光标 start */
    color: transparent;
    text-shadow: 0 0 0 #000;
    /* 隐藏光标 end */

    /* 隐藏ios设备光标 start */
    text-indent: -999em;
    margin-left: -40%;
    /* 隐藏ios设备光标 end */
  }
}
.bs-password-input-security{
  position: relative;
  z-index: 5;
  display: flex;
  height: 40px;
  user-select: none;
  background-color: #fff;
}
.bs-password-input-item{
  position: relative;
  z-index: 5;
  display: flex;
  flex: 1;
  justify-content: center;
  align-items: center;
  height: 100%;
  cursor: pointer;
  font-size: 20px;
  background-color: #F2F2F2;
  &:not(:first-child)::before{
    position: absolute;
    top: 0;
    left: 0;
    bottom: 0;
    content: ' ';
    width: 1px;/*no*/
    background-color: #ececec;
  }
  &.is-focus{
    .password-input-dot{
      visibility: hidden;
    }
    .bs-password-input-cursor{
      display: block;
    }
  }
}
.password-input-dot{
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background-color: #000;
}
.bs-password-input-cursor{
  display: none;
  position: absolute;
  top: 50%;
  left: 50%;
  width: 1px;/*no*/
  height: 40%;
  transform: translate(-50%, -50%);
  cursor: pointer;
  background-color: rgba(32,32,32,3);
  animation: 1s cursor-flicker infinite;
}

.bs-password-input-security{
  &.has-gap{
    .bs-password-input-item{
      border-radius: 4px;
      &::before{
        display: none;
      }
      &:not(:first-child){
        margin-left: 15px;
      }
    }
  }
}
.bs-password-input-info {
  margin-top: 15px;
  color: #999;
  text-align: center;
}


@keyframes cursor-flicker {
  0% {
    opacity: 0;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

使用

<template>
<div class="pay">
  <PasswordInput
    ref="passwordInputRef"
    v-model="password"
    has-gap
    @complete="onPasswordInputComplete"></PasswordInput>
</div>
</template>

<script>
import {
  ref,
  onMounted
} from 'vue';
import PasswordInput from './PasswordInput';

export default {
  name: "PayDialog",
  components: {
    PasswordInput
  },
  setup (props, ctx) {
    let passwordInputRef = ref(null);
    let password = ref('');

    let onPasswordInputComplete = function (pwd) {
      console.log('密码输入完成: ', pwd);
    };

    onMounted(function () {
      // 让密码框获得焦点
      passwordInputRef.value.focus();
    });

    return {
      password,
      passwordInputRef,

      onPasswordInputComplete
    };
  }
};
</script>