Maison  >  Article  >  interface Web  >  Native JS implémente la méthode d'instance de composant de déverrouillage par geste (image)

Native JS implémente la méthode d'instance de composant de déverrouillage par geste (image)

PHPz
PHPzoriginal
2017-04-23 09:22:462215parcourir

Il s'agit de la question de sélection du troisième projet 360 Front-end Star. Plus de 600 étudiants ont répondu aux questions et 60 étudiants ont finalement réussi. Ces 60 étudiants ont fait du bon travail et leurs idées, leur style de codage et l'achèvement des fonctions sont assez prometteurs. Cependant, il y a aussi certaines choses qui ne sont pas prises en compte. Par exemple, ils ont constaté que de nombreux étudiants peuvent implémenter des fonctions complètes selon les exigences. , mais ils ne savent pas comment le faire Concevoir une API ouverte, ou en d'autres termes, comment analyser et prédire les exigences du produit et les changements futurs pour décider de ce qui doit être ouvert et ce qui doit être encapsulé. Il ne s’agit pas de savoir si la réponse est correcte ou non, c’est une question d’expérience.

Ici, je fournis une version de référence. Cela ne signifie pas que cette version est la meilleure, mais que grâce à cette version, nous pouvons analyser quand nous rencontrons des exigences d'interface utilisateur aussi complexes. Comment devrions-nous penser et mettre en œuvre.

Native JS implémente la méthode d'instance de composant de déverrouillage par geste (image)

Étapes générales de la conception des composants

La conception des composants comprend généralement les processus suivants :

  1. Compréhension des exigences

  2. Sélection technique

  3. Conception structurelle (UI)

  4. Conception de données et d'API

  5. Conception de processus

  6. Compatibilité et optimisation des détails

  7. Outils et ingénierie

Ces processus ne sont pas rencontrés lors de la conception de chaque composant, mais de manière générale, un projet rencontrera toujours des problèmes qui doivent être résolus au cours de certains de ces processus. Analysons-le brièvement ci-dessous.

Comprendre les exigences

La mission elle-même consiste simplement à concevoir une interaction commune gestemot de passe avec l'interface utilisateur. Vous pouvez basculer entre les deux états en choisissant de vérifier le. mot de passe et définissez le mot de passe , chaque statut a son propre processus. Par conséquent, la plupart des étudiants encapsulent le changement d'état et le processus de l'ensemble du composant en fonction des besoins. Certains étudiants offrent certaines capacités de configuration de style interface utilisateur, mais fondamentalement, aucun étudiant ne peut ouvrir les nœuds dans le processus de changement d'état. En fait, si ce composant doit être utilisé par les utilisateurs, il doit évidemment ouvrir le nœud du processus. En d'autres termes, l'utilisateur doit décider quelles opérations effectuer dans le processus de définition du mot de passe, le processus de vérification du mot de passe, et quelles opérations effectuer une fois la vérification du mot de passe réussie. C'est une décision que les développeurs de composants ne peuvent pas prendre au nom des utilisateurs.

var password = '11121323';

var locker = new HandLock.Locker({
  container: document.querySelector('#handlock'),
  check: {
    checked: function(res){
      if(res.err){
        console.error(res.err); //密码错误或长度太短
        [执行操作...]
      }else{
        console.log(`正确,密码是:${res.records}`);
        [执行操作...]
      }
    },
  },
  update:{
    beforeRepeat: function(res){
      if(res.err){
        console.error(res.err); //密码长度太短
        [执行操作...]
      }else{
        console.log(`密码初次输入完成,等待重复输入`);
        [执行操作...]
      }
    },
    afterRepeat: function(res){
      if(res.err){
        console.error(res.err); //密码长度太短或者两次密码输入不一致
        [执行操作...]
      }else{
        console.log(`密码更新完成,新密码是:${res.records}`);
        [执行操作...]
      }
    },
  }
});

locker.check(password);

Sélection technique

Le cœur de l'affichage de l'interface utilisateur de ce problème est la grille de neuf carrés et le petit point sélectionné Techniquement parlant, nous en avons trois. options Solution : DOM/Canvas/SVG, tous les trois peuvent implémenter l'interface utilisateur principale.

Si vous utilisez DOM, le moyen le plus simple est d'utiliser une mise en page flexible, qui peut être rendue réactive.

DOM implémente le dessin

L'avantage d'utiliser DOM est qu'il est facile de mettre en œuvre la réactivité, le traitement des événements est simple et la mise en page n'est pas compliquée (mais c'est légèrement plus compliqué que Canvas), mais la longueur et la pente de la ligne diagonale (non dessinée dans la démo) doivent être calculées.

En plus d'utiliser DOM, il est également très pratique d'utiliser Canvas pour dessiner :

Canvas implémente le dessin

Il y a deux petits détails dans l'utilisation de Canvas Le premier est. pour obtenir de la réactivité. Vous pouvez utiliser DOM pour construire un conteneur carré :

#container {
  position: relative;
  overflow: hidden;
  width: 100%;
  padding-top: 100%;
  height: 0px;
  background-color: white;
}

Ici, nous utilisons padding-top:100% pour étendre la hauteur du conteneur afin qu'elle soit égale à la hauteur. largeur du conteneur.

Le deuxième détail est que afin d'obtenir un effet d'affichage clair sur l'écran Retina, nous doublons la largeur et la hauteur du Canvas, puis la réduisons pour qu'elle corresponde à la largeur et à la hauteur du conteneur par transformation : échelle (0,5).

#container canvas{
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%) scale(0.5);
}

Étant donné que le positionnement de Canvas est absolu, sa largeur et sa hauteur par défaut ne sont pas égales à la largeur et à la hauteur du conteneur et doivent être définies via JS :

let width = 2 * container.getBoundingClientRect().width;
canvas.width = canvas.height = width;

De cette façon, nous pouvons implémenter l'interface utilisateur en dessinant des cercles pleins et des lignes connectées sur Canvas. La méthode spécifique sera expliquée plus en détail dans le contenu suivant.

Enfin, jetons un œil au dessin avec SVG :

Implémentation SVG du dessin

L'API pour les opérations SVG natives n'étant pas très pratique, la bibliothèque Snap.svg est utilisé ici pour l'implémenter. C'est très similaire à l'utilisation de Canvas, je n'entrerai donc pas dans les détails ici.

Le problème avec SVG est que sa compatibilité mobile n'est pas aussi bonne que DOM et Canvas.

Sur la base des trois situations ci-dessus, j'ai finalement choisi d'utiliser Canvas pour l'implémenter.

Conception structurelle

La structure DOM est relativement simple si elle est implémentée à l'aide de Canvas. Afin d'être réactif, nous devons implémenter un conteneur carré avec une largeur adaptative. La méthode a déjà été introduite. Créez ensuite un Canvas dans le conteneur. Une chose à noter ici est que nous devons superposer Canvas. En effet, dans le mécanisme de rendu de Canvas, afin de mettre à jour le contenu du canevas, la zone à mettre à jour doit être actualisée et redessinée. Étant donné que nous devons gérer un contenu qui change fréquemment et un contenu fondamentalement inchangé en couches, cela peut améliorer considérablement les performances.

Divisé en 3 couches

Native JS implémente la méthode d'instance de composant de déverrouillage par geste (image)

在这里我把 UI 分别绘制在 3 个图层里,对应 3 个 Canvas。最上层只有随着手指头移动的那个线段,中间是九个点,最下层是已经绘制好的线。之所以这样分,是因为随手指头移动的那条线需要不断刷新,底下两层都不用频繁更新,但是把连好的线放在最底层是因为我要做出圆点把线的一部分遮挡住的效果。

确定圆点的位置

Native JS implémente la méthode d'instance de composant de déverrouillage par geste (image)

圆点的位置有两种定位法,第一种是九个九宫格,圆点在小九宫格的中心位置。如果认真的同学,已经发现在前面 DOM 方案里,我们就是采用这样的方式,圆点的直径为 11.1%。第二种方式是用横竖三条线把宽高四等分,圆点在这些线的交点处。

在 Canvas 里我们采用第二种方法来确定圆点(代码里的 n = 3)。

let range = Math.round(width / (n + 1));

let circles = [];

//drawCircleCenters
for(let i = 1; i <= n; i++){
  for(let j = 1; j <= n; j++){
    let y = range * i, x = range * j;
    drawSolidCircle(circleCtx, fgColor, x, y, innerRadius);
    let circlePoint = {x, y};
    circlePoint.pos = [i, j];
    circles.push(circlePoint);
  }
}

最后一点,严格说不属于结构设计,但是因为我们的 UI 是通过触屏操作,我们需要考虑 Touch 事件处理和坐标的转换。

function getCanvasPoint(canvas, x, y){
  let rect = canvas.getBoundingClientRect();
  return {
    x: 2 * (x - rect.left), 
    y: 2 * (y - rect.top),
  };
}

我们将 Touch 相对于屏幕的坐标转换为 Canvas 相对于画布的坐标。代码里的 2 倍是因为我们前面说了要让 retina 屏下清晰,我们将 Canvas 放大为原来的 2 倍。

API 设计

接下来我们需要设计给使用者使用的 API 了。在这里,我们将组件功能分解一下,独立出一个单纯记录手势的 Recorder。将组件功能分解为更加底层的组件,是一种简化组件设计的常用模式。

Native JS implémente la méthode d'instance de composant de déverrouillage par geste (image)

我们抽取出底层的 Recorder,让 Locker 继承 Recorder,Recorder 负责记录,Locker 管理实际的设置和验证密码的过程。

我们的 Recorder 只负责记录用户行为,由于用户操作是异步操作,我们将它设计为 Promise 规范的 API,它可以以如下方式使用:

var recorder = new HandLock.Recorder({
  container: document.querySelector(&#39;#main&#39;)
});

function recorded(res){
  if(res.err){
    console.error(res.err);
    recorder.clearPath();
    if(res.err.message !== HandLock.Recorder.ERR_USER_CANCELED){
      recorder.record().then(recorded);
    }
  }else{
    console.log(res.records);
    recorder.record().then(recorded);
  }      
}

recorder.record().then(recorded);

对于输出结果,我们简单用选中圆点的行列坐标拼接起来得到一个唯一的序列。例如 "11121323" 就是如下选择图形:

Native JS implémente la méthode d'instance de composant de déverrouillage par geste (image)

为了让 UI 显示具有灵活性,我们还可以将外观配置抽取出来。

const defaultOptions = {
  container: null, //创建canvas的容器,如果不填,自动在 body 上创建覆盖全屏的层
  focusColor: &#39;#e06555&#39;,  //当前选中的圆的颜色
  fgColor: &#39;#d6dae5&#39;,     //未选中的圆的颜色
  bgColor: &#39;#fff&#39;,        //canvas背景颜色
  n: 3, //圆点的数量: n x n
  innerRadius: 20,  //圆点的内半径
  outerRadius: 50,  //圆点的外半径,focus 的时候显示
  touchRadius: 70,  //判定touch事件的圆半径
  render: true,     //自动渲染
  customStyle: false, //自定义样式
  minPoints: 4,     //最小允许的点数
};

这样我们实现完整的 Recorder 对象,核心代码如下:

[...] //定义一些私有方法

const defaultOptions = {
  container: null, //创建canvas的容器,如果不填,自动在 body 上创建覆盖全屏的层
  focusColor: &#39;#e06555&#39;,  //当前选中的圆的颜色
  fgColor: &#39;#d6dae5&#39;,     //未选中的圆的颜色
  bgColor: &#39;#fff&#39;,        //canvas背景颜色
  n: 3, //圆点的数量: n x n
  innerRadius: 20,  //圆点的内半径
  outerRadius: 50,  //圆点的外半径,focus 的时候显示
  touchRadius: 70,  //判定touch事件的圆半径
  render: true,     //自动渲染
  customStyle: false, //自定义样式
  minPoints: 4,     //最小允许的点数
};

export default class Recorder{
  static get ERR_NOT_ENOUGH_POINTS(){
    return 'not enough points';
  }
  static get ERR_USER_CANCELED(){
    return 'user canceled';
  }
  static get ERR_NO_TASK(){
    return 'no task';
  }
  constructor(options){
    options = Object.assign({}, defaultOptions, options);

    this.options = options;
    this.path = [];

    if(options.render){
      this.render();
    }
  }
  render(){
    if(this.circleCanvas) return false;

    let options = this.options;
    let container = options.container || document.createElement('p');

    if(!options.container && !options.customStyle){
      Object.assign(container.style, {
        position: 'absolute',
        top: 0,
        left: 0,
        width: '100%',
        height: '100%',
        lineHeight: '100%',
        overflow: 'hidden',
        backgroundColor: options.bgColor
      });
      document.body.appendChild(container); 
    }
    this.container = container;

    let {width, height} = container.getBoundingClientRect();

    //画圆的 canvas,也是最外层监听事件的 canvas
    let circleCanvas = document.createElement('canvas'); 

    //2 倍大小,为了支持 retina 屏
    circleCanvas.width = circleCanvas.height = 2 * Math.min(width, height);
    if(!options.customStyle){
      Object.assign(circleCanvas.style, {
        position: 'absolute',
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%) scale(0.5)', 
      });
    }

    //画固定线条的 canvas
    let lineCanvas = circleCanvas.cloneNode(true);

    //画不固定线条的 canvas
    let moveCanvas = circleCanvas.cloneNode(true);

    container.appendChild(lineCanvas);
    container.appendChild(moveCanvas);
    container.appendChild(circleCanvas);

    this.lineCanvas = lineCanvas;
    this.moveCanvas = moveCanvas;
    this.circleCanvas = circleCanvas;

    this.container.addEventListener('touchmove', 
      evt => evt.preventDefault(), {passive: false});

    this.clearPath();
    return true;
  }
  clearPath(){
    if(!this.circleCanvas) this.render();

    let {circleCanvas, lineCanvas, moveCanvas} = this,
        circleCtx = circleCanvas.getContext('2d'),
        lineCtx = lineCanvas.getContext('2d'),
        moveCtx = moveCanvas.getContext('2d'),
        width = circleCanvas.width,
        {n, fgColor, innerRadius} = this.options;

    circleCtx.clearRect(0, 0, width, width);
    lineCtx.clearRect(0, 0, width, width);
    moveCtx.clearRect(0, 0, width, width);

    let range = Math.round(width / (n + 1));

    let circles = [];

    //drawCircleCenters
    for(let i = 1; i <= n; i++){
      for(let j = 1; j <= n; j++){
        let y = range * i, x = range * j;
        drawSolidCircle(circleCtx, fgColor, x, y, innerRadius);
        let circlePoint = {x, y};
        circlePoint.pos = [i, j];
        circles.push(circlePoint);
      }
    }

    this.circles = circles;
  }
  async cancel(){
    if(this.recordingTask){
      return this.recordingTask.cancel();
    }
    return Promise.resolve({err: new Error(Recorder.ERR_NO_TASK)});
  }
  async record(){
    if(this.recordingTask) return this.recordingTask.promise;

    let {circleCanvas, lineCanvas, moveCanvas, options} = this,
        circleCtx = circleCanvas.getContext('2d'),
        lineCtx = lineCanvas.getContext('2d'),
        moveCtx = moveCanvas.getContext('2d');

    circleCanvas.addEventListener('touchstart', ()=>{
      this.clearPath();
    });

    let records = [];

    let handler = evt => {
      let {clientX, clientY} = evt.changedTouches[0],
          {bgColor, focusColor, innerRadius, outerRadius, touchRadius} = options,
          touchPoint = getCanvasPoint(moveCanvas, clientX, clientY);

      for(let i = 0; i < this.circles.length; i++){
        let point = this.circles[i],
            x0 = point.x,
            y0 = point.y;

        if(distance(point, touchPoint) < touchRadius){
          drawSolidCircle(circleCtx, bgColor, x0, y0, outerRadius);
          drawSolidCircle(circleCtx, focusColor, x0, y0, innerRadius);
          drawHollowCircle(circleCtx, focusColor, x0, y0, outerRadius);

          if(records.length){
            let p2 = records[records.length - 1],
                x1 = p2.x,
                y1 = p2.y;

            drawLine(lineCtx, focusColor, x0, y0, x1, y1);
          }

          let circle = this.circles.splice(i, 1);
          records.push(circle[0]);
          break;
        }
      }

      if(records.length){
        let point = records[records.length - 1],
            x0 = point.x,
            y0 = point.y,
            x1 = touchPoint.x,
            y1 = touchPoint.y;

        moveCtx.clearRect(0, 0, moveCanvas.width, moveCanvas.height);
        drawLine(moveCtx, focusColor, x0, y0, x1, y1);        
      }
    };


    circleCanvas.addEventListener('touchstart', handler);
    circleCanvas.addEventListener('touchmove', handler);

    let recordingTask = {};
    let promise = new Promise((resolve, reject) => {
      recordingTask.cancel = (res = {}) => {
        let promise = this.recordingTask.promise;

        res.err = res.err || new Error(Recorder.ERR_USER_CANCELED);
        circleCanvas.removeEventListener('touchstart', handler);
        circleCanvas.removeEventListener('touchmove', handler);
        document.removeEventListener('touchend', done);
        resolve(res);
        this.recordingTask = null;

        return promise;
      }

      let done = evt => {
        moveCtx.clearRect(0, 0, moveCanvas.width, moveCanvas.height);
        if(!records.length) return;

        circleCanvas.removeEventListener('touchstart', handler);
        circleCanvas.removeEventListener('touchmove', handler);
        document.removeEventListener('touchend', done);

        let err = null;

        if(records.length < options.minPoints){
          err = new Error(Recorder.ERR_NOT_ENOUGH_POINTS);
        }

        //这里可以选择一些复杂的编码方式,本例子用最简单的直接把坐标转成字符串
        let res = {err, records: records.map(o => o.pos.join('')).join('')};

        resolve(res);
        this.recordingTask = null;
      };
      document.addEventListener('touchend', done);
    });

    recordingTask.promise = promise;

    this.recordingTask = recordingTask;

    return promise;
  }
}

 

它的几个公开的方法,recorder 负责记录绘制结果, clearPath 负责在画布上清除上一次记录的结果,cancel 负责终止记录过程,这是为后续流程准备的。

流程设计

接下来我们基于 Recorder 来设计设置和验证密码的流程:

验证密码


设置密码

Native JS implémente la méthode d'instance de composant de déverrouillage par geste (image)

有了前面异步 Promise API 的 Recorder,我们不难实现上面的两个流程。

验证密码的内部流程

async check(password){
  if(this.mode !== Locker.MODE_CHECK){
    await this.cancel();
    this.mode = Locker.MODE_CHECK;
  }  

  let checked = this.options.check.checked;

  let res = await this.record();

  if(res.err && res.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(res);
  }

  if(!res.err && password !== res.records){
    res.err = new Error(Locker.ERR_PASSWORD_MISMATCH)
  }

  checked.call(this, res);
  this.check(password);
  return Promise.resolve(res);
}

 

设置密码的内部流程

async update(){
  if(this.mode !== Locker.MODE_UPDATE){
    await this.cancel();
    this.mode = Locker.MODE_UPDATE;
  }

  let beforeRepeat = this.options.update.beforeRepeat, 
      afterRepeat = this.options.update.afterRepeat;

  let first = await this.record();

  if(first.err && first.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(first);
  }

  if(first.err){
    this.update();
    beforeRepeat.call(this, first);
    return Promise.resolve(first);   
  }

  beforeRepeat.call(this, first);

  let second = await this.record();      

  if(second.err && second.err.message === Locker.ERR_USER_CANCELED){
    return Promise.resolve(second);
  }

  if(!second.err && first.records !== second.records){
    second.err = new Error(Locker.ERR_PASSWORD_MISMATCH);
  }

  this.update();
  afterRepeat.call(this, second);
  return Promise.resolve(second);
}

 

可以看到,有了 Recorder 之后,Locker 的验证和设置密码基本上就是顺着流程用 async/await 写下来就行了。

细节问题

实际手机触屏时,如果上下拖动,浏览器有默认行为,会导致页面上下移动,需要阻止 touchmove 的默认事件。

this.container.addEventListener('touchmove', 
      evt => evt.preventDefault(), {passive: false});

 

这里仍然需要注意的一点是, touchmove 事件在 chrome 下默认是一个 Passive Event ,因此 addEventListener 的时候需要传参 {passive: false},否则的话不能 preventDefault。

工具 & 工程化

因为我们的代码使用了 ES6+,所以需要引入 babel 编译,我们的组件也使用 webpack 进行打包,以便于使用者在浏览器中直接引入。

这方面的内容,在之前的博客里有介绍,这里就不再一一说明。

最后,具体的代码可以直接查看 GitHub 工程 。

总结

以上就是今天要讲的全部内容,这里面有几个点我想再强调一下:

  1. 在设计 API 的时候思考真正的需求,判断什么该开放、什么该封装

  2. 做好技术调研和核心方案研究,选择合适的方案

  3. 优化和解决细节问题

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn