🕌

PlatformIOで作るスタックチャン(3):タッチパネルを利用する

に公開

前回のおさらい

前回は、m5stack-avatarを使ってスタックチャンの顔の表示と拡張した左上のメッセージ表示機能をつかって時計の表示を紹介しました。

これまでは、プログラムされた内容を表示することを中心にしましたが、対話ロボットであるスタックチャンを実現するために、外部からの何らかの入力に応じた振舞いをさせたいところです。

RT版のスタックチャンで使用しているM5Stack CoreS3では、ボタンはついていませんがスクリーンがタッチパネルになっているので、仮想的なボタン操作やフリック動作の認識などを実現することができます。

今回は、スクリーン上に矩形エリアを定義して任意の動作を実現する方法について解説していきます。

M5Stack CoreS3のスクリーン

M5Stack CoreS3のスクリーンは、画面サイズ2.0インチ、320x240 Pixelsの解像度をもっています。タッチパネルは、静電容量式が採用されています。
スクリーン上の点を指定する場合には、一般的には下図に示した座標系を使います。この座標系は、原点が左上隅にあり、水平方向をx軸(またはu軸)、垂直方向をy軸(またはv軸)として表現されています。

仮想ボタンを定義する

タッチパネル上に仮想の矩形ボタンを生成するために、仮想ボタンを表現するクラスを定義します。画面上の矩形は左上の頂点の座標、矩形の幅と高さが決まれば一意に表現できますので、クラス変数としては x0, y0, width, heightの4つの整数とします。
また、クラス関数としては、コンストラクタとタッチした点が矩形の内部にあるかどうかを判定する関数(isInside)があれば最低限の要求を満たすと思いますので、下のように定義します。

class RectArea {
public:
    int x0;
    int y0;
    int width;
    int height;
public:
    RectArea(int x, int y, int w, int h):
      x0(x), y0(y),width(w), height(h){ }
      
    bool isInside(int x, int y){
      return (x > x0 && x < x0+width && y > y0 && y < y0+height);
    }
};

仮想ボタンを押下するという処理

次に、タッチパネルにタッチしたときのイベント処理について考えます。M5Stackのタッチパネルのイベントは、M5.Touchクラスで管理しており、getDetail関数を使ってタッチ操作に関する情報(押した、離した、タッチ位置など)を取得することができます。

通常ボタンのようなユーザインターフェースでは、押した位置と離した位置が同一ボタンであれば、それに紐づけた処理を実行するというように実装されています。この理由としては、タッチイベントで「押した」という状況は、パネル表面の滑りや意図しない接触にも反応してしまい、誤動作の原因になるからです。
そのため、多くの場合には、意図的に押したかどうかを判定するには、押した位置と離した位置が一致していることを確認したり、複数の同一イベント発生や一定時間のイベント継続などの処理を行うことで誤動作の回避を行っています。

ここでは、簡単化のために 「仮想ボタンを押した位置と離した位置が、あらかじめ設定したボタン領域に入っているかどうかを判定する」 ロジックを採用します。

  if(M5.Touch.isEnabled()) { // タッチパネルが有効になっているかをチェック
    auto dt = M5.Touch.getDetail(); //タッチイベントの取得
    if(dt.isPressed()){ // タッチ時の処理
      if(Btn.isInside(dt.x, dt.y)) { // タッチ位置がBtnの領域内
        pressed = true;
      } else {
        pressed = false;
      }
    }else if (dt.isReleased()){ // 指が離れた時の処理 
      if (pressed && Btn.isInside(dt.x, dt.y)){ // 直前のイベントでBtnがタッチ状態であった場合
        toggle();
        pressed = false;
      }
    }
  }

上記のプログラムコードでは、bool型の pressedとRectArea型の Btnを予め定義し、タッチパネルから離れた時に、その直前まで同じボタン(RectArea)を押していた場合にtoggle関数を実行しています。つまり、指を離す直前に指がボタン領域に入っていなければ toggle関数は実行されないことになります。

では、中央に 60x60pixelの仮想ボタンを定義し、押下するごとにスタックチャンの顔と仮想ボタンの描画に切り替えるような機能を実装します。

#include <Arduino.h>
#include <M5Unified.h>
#include <Avatar.h>

m5avatar::Avatar avatar;
// 仮想ボタンクラス
class RectArea {
public:
    int x0, y0, width, height;
public:
    RectArea(int x, int y, int w, int h):
      x0(x), y0(y),width(w), height(h){}
      
    // 押下した位置のが領域内に入っているかどうかの判定
    bool isInside(int x, int y){
      return (x > x0 && x < x0+width && y > y0 && y < y0+height);
    }
};

// 仮想ボタンの定義
RectArea Btn(160-30, 120-30, 60, 60);
// 仮想ボタンを押下したときに実行する関数
void toggle(){
  if(avatar.isDrawing()){ // 顔を描画中であれば…
    avatar.stop();
    delay(100); // 顔の描画が停止するまで、少し待つ
    M5.Display.clearDisplay();
    M5.Display.drawRect(Btn.x0, Btn.y0, Btn.width, Btn.height, TFT_WHITE);
  }else{ // 顔の描画を再開
    avatar.init(8);
  }
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  // Enable Log
  M5.Log.setLogLevel(m5::log_target_serial, ESP_LOG_INFO);
  M5.Log.setEnableColor(m5::log_target_serial, true);

  // 8bit color-mode
  avatar.init(8);
}

bool pressed = false;

void loop() {
  M5.update();
  // タッチパネルのイベント処理
  if(M5.Touch.isEnabled()) {
    auto dt = M5.Touch.getDetail();
    if(dt.isPressed()){
      if(Btn.isInside(dt.x, dt.y)) {
        M5_LOGI("PressBtn"); 
        pressed = true;
      } else {
        pressed = false;
      }
    }else if (dt.isReleased()){
      if (pressed && Btn.isInside(dt.x, dt.y)){
        toggle();
        pressed = false;
      }
    }
  }
}

上記のプログラムをビルド&アップロードし、M5Stackを再起動させてください。
起動後は、スタックチャンの顔が表示され、画面の中央をタッチすると、顔とボタンの表示が切り替われば動作確認は完了です。

タッチイベント発生後にすぐに処理を呼び出す

前の節では、仮想ボタンから指が離れた時に処理を呼び出すようにしていました。
この場合、実装によってはもたつきを感じることがあるため、タッチしたときにすぐに処理を開始するように変更してみます。

この変更は、実装方法によってはチャタリングが発生し、意図した動作にならない場合もありますので、十分な注意が必要になります。
ここでは、人のタッチするタイミングを考慮して、適度にdelay(休止)を入れることにします。
休止させる時間は、処理するデバイスにも依存しますが、タッチして処理の実行完了まで300msecはあった方が良いと思います。

前述のプログラムでは、toggle関数で100msecの休止をいれていますので、下記のように200msecの休止を入れるように改修します。

  // タッチパネルのイベント処理
  if(M5.Touch.isEnabled()) {
    auto dt = M5.Touch.getDetail();
    if(dt.isPressed()){
      if(Btn.isInside(dt.x, dt.y)) {
        toggle();
        delay(200);
      }
  }
}

この改修を行った後、プログラムをビルド&アップロード後、M5Stackを再起動して動作を確認して下さい。もし、チャタリングなどの不具合が発生した場合には、上記の休止時間を延ばしてみると良いと思います。

RectAreaクラスの改修

前述のプログラムで、タッチパネルを利用した仮想ボタンが実現できることがわかりました。しかし、このままでは複数のボタンを付けたり、ボタンごとに実行する処理を割り付けたりするとプログラム全体が複雑になりメンテナンス性が良くありません。
そこで、RectAreaのインスタンスごとに関数(コールバック関数)を割り付ける機能、標準的なイベント処理を実装し、見通しを良くします。

RectAreaクラスに関数ポインタを含める

ReatAreaのクラス変数に、実行する関数の関数ポインタを含めます。また、矩形描画やコールバック関数の実行などもクラス関数化し、クラス変数の隠蔽も進めます。

class RectArea {
private:
    int x0;
    int y0;
    int width;
    int height;
    std::function<void()> _callback; // コールバック関数ポインタ
    String label; // 識別ラベル
public:
    RectArea(int x, int y, int w, int h, String label=""):
      x0(x), y0(y),width(w), height(h){ this->label = label; }

    bool isInside(int x, int y){
        return (x > x0 && x < x0+width && y > y0 && y < y0+height);
    }

    bool hasCallback(){ // コールバック関数の有無を確認
        if(_callback) { return true; }
        return false;
    }

    void registerCallback(std::function<void()> func) { // コールバック関数の登録
        _callback = func;
    }

    bool update(int x, int y) { // 領域判定とコールバック関数の実行
      if (hasCallback() && isInside(x, y) ){
        _callback();
        return true;
      }
      return false;
    }
    
    void setLabel(String lbl) { label = lbl; }  // 識別ラベルの登録
    String getLabel() { return label; }  // 識別ラベルの取得
    void show() { // ボタンの描画
      M5.Display.drawRect(x0, y0, width, height, TFT_WHITE);
      if(label){
        M5.Display.setTextSize(1);
        M5.Display.setTextDatum(MC_DATUM); // 中央揃え
        M5.Display.setCursor(x0+width/2 -20,y0+height/2);
        M5.Display.print(label);
      }
   }
};

この改修したRectAreaクラスを使って、前のプログラムを書き直すと下のようになります。

#include <Arduino.h>
#include <M5Unified.h>
#include <Avatar.h>
#include <functional> /// std::functionの利用

m5avatar::Avatar avatar;

class RectArea {
private:
    int x0;
    int y0;
    int width;
    int height;
    std::function<void()> _callback; // コールバック関数ポインタ
    String label; // 識別ラベル
public:
    RectArea(int x, int y, int w, int h, String label=""):
      x0(x), y0(y),width(w), height(h){ this->label = label; }

    bool isInside(int x, int y){
        return (x > x0 && x < x0+width && y > y0 && y < y0+height);
    }

    bool hasCallback(){ // コールバック関数の有無を確認
        if(_callback) { return true; }
        return false;
    }

    void registerCallback(std::function<void()> func) { // コールバック関数の登録
        _callback = func;
    }

    bool update(int x, int y) { // 領域判定とコールバック関数の実行
      if (hasCallback() && isInside(x, y) ){
        _callback();
        return true;
      }
      return false;
    }
    
    void setLabel(String lbl) { label = lbl; }  // 識別ラベルの登録
    String getLabel() { return label; }  // 識別ラベルの取得
    void show() { // ボタンの描画
      M5.Display.drawRect(x0, y0, width, height, TFT_WHITE);
      if(label){
        M5.Display.setTextSize(1);
        M5.Display.setTextDatum(MC_DATUM); // 中央揃え
        M5.Display.setCursor(x0+width/2 -20,y0+height/2);
        M5.Display.print(label);
      }
   }
};

// Btn
RectArea Btn(160-30, 120-30, 60, 60, "Btn");
void toggle(){
  if(avatar.isDrawing()){
    avatar.stop();
    delay(100);
    M5.Display.clearDisplay();
    Btn.show();
  }else{
    avatar.init(8);
  }
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  // Enable Log
  M5.Log.setLogLevel(m5::log_target_serial, ESP_LOG_INFO);
  M5.Log.setEnableColor(m5::log_target_serial, true);
  
  // コールバック関数の登録
  Btn.registerCallback(toggle);
  // 8bit color-mode
  avatar.init(8);
}

void loop() {
  M5.update();
  if(M5.Touch.isEnabled()) {
    auto dt = M5.Touch.getDetail();
    if(dt.isPressed()){
      if(Btn.update(dt.x, dt.y)) { 
        delay(200); 
      }
    }
  }
}

PlatformIOでは、プログラムファイルを複数のファイルに分割することができます。RectAreaクラスを TouchButon.h, TouchButton.cppに分割して、プログラム全体の見通しを良くしましょう。

TouchButton.h

このファイルは、仮想ボタンのクラス定義です。
下のような内容で作成します。
このファイルの 「#pragma once」 という記述は、ヘッダー ファイルを 1 回だけインクルードすることを指定するためのプリプロセッサです。

#pragma once
#include <Arduino.h>
#include <M5Unified.h>
#include <functional>

class RectArea {
private:
    int x0;
    int y0;
    int width;
    int height;
    std::function<void()> _callback;
    String label;
public:
    RectArea(int x, int y, int w, int h, String label=""):
      x0(x), y0(y),width(w), height(h){ this->label = label; }

    bool isInside(int x, int y){
        return (x > x0 && x < x0+width && y > y0 && y < y0+height);
    }

    bool hasCallback(){
        if(_callback) { return true; }
        return false;
    }

    void registerCallback(std::function<void()> func){
        _callback = func;
    }
    void setLabel(String lbl) { label = lbl; }
    String getLabel() { return label; }
    bool update(int x, int y);
    void show();
};

TouchButton.cpp

TouchButton.hで定義したクラスのメンバ関数の定義です。下のように作成します。

#include "TouchButton.h"

bool RectArea::update(int x, int y) {
  if (hasCallback() && isInside(x, y) ){
    _callback();
    return true;
  }
  return false;
}

void RectArea::show(){
  M5.Display.drawRect(x0, y0, width, height, TFT_WHITE);
  if(label){
    M5.Display.setTextSize(1);
    M5.Display.setTextDatum(MC_DATUM);
    M5.Display.setCursor(x0+width/2 -20,y0+height/2);
    M5.Display.print(label);
  }
}

main.cpp

上記の2つを利用して前のプログラムを書き直します。

#include <TouchButton.h>
#include <Avatar.h>

m5avatar::Avatar avatar;

// Btn
RectArea Btn(160-30, 120-30, 60, 60, "Btn");
void toggle(){
  if(avatar.isDrawing()){
    avatar.stop();
    delay(100);
    M5.Display.clearDisplay();
    Btn.show();
  }else{
    avatar.init(8);
  }
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  // Enable Log
  M5.Log.setLogLevel(m5::log_target_serial, ESP_LOG_INFO);
  M5.Log.setEnableColor(m5::log_target_serial, true);
  
  // コールバック関数の登録
  Btn.registerCallback(toggle);
  // 8bit color-mode
  avatar.init(8);
}

void loop() {
  M5.update();
  if(M5.Touch.isEnabled()) {
    auto dt = M5.Touch.getDetail();
    if(dt.isPressed()){
      if(Btn.update(dt.x, dt.y)) { 
        delay(200); 
      }
    }
  }
}

仮想ボタンを増やす

では、複数の仮想ボタンを作成して、各ボタンに個別の処理を割り当ててみましょう。
ここで作成する仮想ボタンは、M5Stack Basicのように画面の下部に3つの仮想ボタンを作成します。

それぞれの仮想ボタンで呼び出される処理は、以下の通りにします。

  • BtnA: 顔描画の停止と再開
  • BtnB: 顔の描画中であれば、顔の表情の変更(Neutral ⇔ Happy)
  • BtnC: 顔の描画中であれば、顔を左右に振る
#include <TouchButton.h>
#include <Avatar.h>

m5avatar::Avatar avatar;
// BtnAの定義とコールバック関数
RectArea BtnA(5, 200, 100, 40, "BtnA");
void callbackBtnA(){
  if(avatar.isDrawing()){
    avatar.stop();
    delay(100);
    M5.Display.clearDisplay();
    BtnA.show();
  }else{
    avatar.init(8);
  }
}
// BtnBの定義とコールバック関数
RectArea BtnB(110, 200, 100, 40, "BtnB");
void callbackBtnB(){
  if(avatar.isDrawing()){
    if(avatar.getExpression() == m5avatar::Expression::Neutral){
      avatar.setExpression(m5avatar::Expression::Happy);
    }else{
      avatar.setExpression(m5avatar::Expression::Neutral);
    }
  }else{
    M5.Display.setCursor(0, 60);
    M5.Display.print("Press BtnB");
  }
}
// BtnCの定義とコールバック関数
RectArea BtnC(215, 200, 100, 40, "BtnC");
void callbackBtnC(){
  if(avatar.isDrawing()){
    avatar.setRotation(avatar.getBreath() * 30);
  }else{
    M5.Display.setCursor(0, 100);
    M5.Display.print("Press BtnC");
  }
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  // Enable Log
  M5.Log.setLogLevel(m5::log_target_serial, ESP_LOG_INFO);
  M5.Log.setEnableColor(m5::log_target_serial, true);

  // コールバック関数の登録
  BtnA.registerCallback(callbackBtnA);
  BtnB.registerCallback(callbackBtnB);
  BtnC.registerCallback(callbackBtnC);
  // 8bit color-mode
  avatar.init(8);
}

void loop() {
  M5.update();
  // タッチイベント処理
  if(M5.Touch.isEnabled()) {
    auto dt = M5.Touch.getDetail();
    if(dt.isPressed()){
      if(BtnA.update(dt.x, dt.y)) {
        delay(200);
      }else if (BtnB.update(dt.x, dt.y)){
        delay(200);
      }else if (BtnC.update(dt.x, dt.y)){
        delay(1);
      }
    }
  }
}

上記のプログラムを作成後、ビルド&アップロードし、M5Stackを再起動し動作確認を行ってください。
各ボタンで設定した処理が実行されれば動作確認は完了です。

フリックモーションの認識

前節までで仮想ボタンについて述べてきました。M5Stackのタッチパネルでは、単なるタッチイベントの他にドラッグ、フリック、ホールディングなどのイベントを取得することができます。(
参照:Touch Class
Arduino版のスタックチャンのファームウェアでもフリック動作を使って、簡単な首振り動作を実装しています。

フリックモーションのイベントは、前述のプログラムと同様に M5.TouchクラスのgetDetail関数で取得可能です。
フリックモーション関連の関数して、wasFlickStart関数とwasFlicked関数があり、フック発生時の位置およびフリック終了時の移動距離を組み合わせることで、上下左右のフリックの識別とフリック発生のエリアを区別することが可能です。

フリック動作の認識として、フリック開始点とフリックの長さに着目してロジックを構成してみます。
フリック動作イベントの処理としては、前述のボタンの上部を開始点とし、水平方向のフリック長を100Pixel以上、垂直方向のフリック長を60pixel以上と定義してみると下のようなプログラムになります。

    auto dt = M5.Touch.getDetail();
    if(dt.wasFlickStart()){
      // フリック開始点は、下部のボタン上部
      M5_LOGI("FlickStart(%d, %d)", dt.x, dt.y);
      if(dt.y < 200){
        flickFlag = 1;
      }
    }
    if(flickFlag && dt.wasFlicked()){ // フリック終わりの時にフリック長を検証
      M5_LOGI("Flicked(%d, %d)", dt.distanceX(), dt.distanceY());
      int dx = dt.distanceX();
      int dy = dt.distanceY();
      if (dy < 50 && dy > -50){
        if (dx > 100) {
          M5_LOGI("Flick Right.");
        }else if(dx < -100){
          M5_LOGI("Flick Left");
        }
      } else if (dx < 30 && dx > -30){
          if (dy > 60) {
          M5_LOGI("Flick Down.");
        }else if(dy < -60){
          M5_LOGI("Flick Up");
        }
      }
      flickFlag=0;
    }

上記のプログラムは、フリックイベントの検出のみですので、各フリックのイベント発生時にスタックチャンの目を上下左右に動かす処理を追加していきます。

スタックチャンの目の動きは Avatarクラスの setRightGaze関数とsetLeftGaze関数を使用します。ここで注意してほしい点として、Avatarクラスは、各関数の引数の順序が virtual(y方向), horizontal(x方向)になっており、スクリーンの座標系とは順序が逆になっています。

これらのことを踏まえてプログラムを作成すると、下のようなプログラムになります。
(分かり易くするために、前述の仮想ボタンの処理を省いています)

#include <TouchButton.h>
#include <Avatar.h>

m5avatar::Avatar avatar;

int flickFlag;

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  // Enable Log
  M5.Log.setLogLevel(m5::log_target_serial, ESP_LOG_INFO);
  M5.Log.setEnableColor(m5::log_target_serial, true);

  // 8bit color-mode
  avatar.init(8);
}

void loop() {
  M5.update();
  //タッチイベント処理
  if(M5.Touch.isEnabled()) {
    auto dt = M5.Touch.getDetail();
    if(dt.wasFlickStart()){
      M5_LOGI("FlickStart(%d, %d)", dt.x, dt.y);
      if(dt.y < 200){
        flickFlag = 1;
      }
    }
    if(flickFlag && dt.wasFlicked()){
      M5_LOGI("Flicked(%d, %d)", dt.distanceX(), dt.distanceY());
      int dx = dt.distanceX();
      int dy = dt.distanceY();
      if (dy < 50 && dy > -50){
        if (dx > 100) {
          M5_LOGI("Flick Right.");
          avatar.setRightGaze(0,5);
          avatar.setLeftGaze(0,5);
        }else if(dx < -100){
          M5_LOGI("Flick Left");
          avatar.setRightGaze(0,-5);
          avatar.setLeftGaze(0,-5);
        }
      } else if (dx < 30 && dx > -30){
          if (dy > 60) {
          M5_LOGI("Flick Down.");
          avatar.setRightGaze(5,0);
          avatar.setLeftGaze(5,0);
        }else if(dy < -60){
          M5_LOGI("Flick Up");
          avatar.setRightGaze(-5,0);
          avatar.setLeftGaze(-5,0);
        }
      }
      flickFlag=0;
    }
  }
}

このプログラムをビルド&アップロードし、M5Stackを再起動して動作を確認してみてください。
画面上を上下左右方向のフリック動作に応じて、スタックチャンの目が同じ方向に動けば動作確認完了になります。

次回は…

今回はタッチパネルをつかったイベント処理について述べました。ボタンの押下やフリックの他にも長押しなどのイベントも取得できますので、アプリの開発時にはAPIマニュアルを参照して下さい。

さて、次回はスタックチャンのモーター制御のライブラリについて解説します。

Discussion