「Arduinoでちゃんとしたメニュー画面って作れないの?」

そう思ったことはありませんか?ボタンひとつで動作を切り替えるだけじゃ物足りなくなってきたとき、ロータリーエンコーダー+OLEDディスプレイの組み合わせはすごく頼りになります。

くるくる回してメニューを選び、ぱちっと押して確定する——そのシンプルな操作感、実はArduinoで再現できちゃうんです。

今回はOLEDディスプレイ(SSD1306)+ロータリーエンコーダーを組み合わせて、実用的なメニューUI(設定画面)を実装する方法を丁寧に解説します。

この記事で作れるもの

  • ロータリーエンコーダーで上下移動できるメニューリスト
  • 項目を選択すると値を増減できる設定画面
  • 「戻る」機能付きの階層メニュー構造

📌 対象読者:Arduinoの基本(Lチカ・シリアル通信)がわかる中級者向けです。

ロータリーエンコーダーってどんな部品?

Arduino OLED display menu
Arduino OLED display menu / Photo by Zulfugar Karimov via Pexels

まずはロータリーエンコーダーについて、ざっくり確認しておきましょう。

ロータリーエンコーダーは、つまみを回した方向と回転量を検出できるセンサーです。見た目はボリューム(可変抵抗)に似ていますが、ボリュームと違って360度無限に回り続けられます

よく使われる「EC11」や「KY-040」というモジュールは以下のピンを持っています。

  • CLK(A):回転パルスその1
  • DT(B):回転パルスその2(CLKと位相がずれている)
  • SW:つまみを押したときのスイッチ信号
  • VCC / GND:電源

CLKとDTの信号の順番を比べることで「右に回ったか、左に回ったか」を判定できます。この仕組みがメニューのカーソル移動にぴったりです!

使用部品と配線

今回必要な部品はこちらです。

  • Arduino Uno(または互換機)× 1
  • OLEDディスプレイ SSD1306(128×64px、I2C接続)× 1
  • ロータリーエンコーダー KY-040 × 1
  • ジャンパーワイヤー、ブレッドボード

配線は以下のようにつないでください。

OLEDディスプレイ(I2C)の配線

  • VCC → Arduino 3.3V または 5V
  • GND → Arduino GND
  • SCL → Arduino A5(UNOのI2Cクロック)
  • SDA → Arduino A4(UNOのI2Cデータ)

ロータリーエンコーダー(KY-040)の配線

  • VCC → Arduino 5V
  • GND → Arduino GND
  • CLK → Arduino D2
  • DT → Arduino D3
  • SW → Arduino D4

📌 D2・D3は割り込み対応ピンなので、エンコーダーの読み取り精度が上がります。

必要なライブラリをインストールしよう

Arduino IDEのライブラリマネージャーから以下をインストールしてください。

  • Adafruit SSD1306(OLEDドライバ)
  • Adafruit GFX Library(グラフィック描画)

ライブラリマネージャーは「スケッチ → ライブラリをインクルード → ライブラリを管理」から開けます。検索バーで「SSD1306」と打てばすぐ見つかりますよ。

ロータリーエンコーダーの読み取りを実装する

まずはエンコーダーの回転方向とボタン押下を確認する小さなスケッチを書いてみましょう。

ポイントをまとめるとこんな感じです👇

  • CLKの立ち下がりエッジ(HIGH→LOW)を割り込みで検出する
  • そのタイミングでDTの値を読み取り、HIGH → 右回り、LOW → 左回りと判定する
  • SWはINPUT_PULLUPで読み取り、LOWのとき押下と判定する
// ロータリーエンコーダーの基本読み取りテスト

#define PIN_CLK 2   // CLK(割り込みピン)
#define PIN_DT  3   // DT
#define PIN_SW  4   // スイッチ

volatile int encoderCount = 0;  // 回転カウント
bool lastSwState = HIGH;        // 前回のスイッチ状態

void setup() {
  Serial.begin(9600);
  pinMode(PIN_CLK, INPUT);
  pinMode(PIN_DT,  INPUT);
  pinMode(PIN_SW,  INPUT_PULLUP);

  // CLKの立ち下がりで割り込み処理を呼び出す
  attachInterrupt(digitalPinToInterrupt(PIN_CLK), readEncoder, FALLING);
}

void loop() {
  // エンコーダーのカウントをシリアルに出力
  static int lastCount = 0;
  if (encoderCount != lastCount) {
    Serial.print("Count: ");
    Serial.println(encoderCount);
    lastCount = encoderCount;
  }

  // スイッチの押下検出(チャタリング対策に10ms待ち)
  bool swState = digitalRead(PIN_SW);
  if (swState == LOW && lastSwState == HIGH) {
    delay(10);
    if (digitalRead(PIN_SW) == LOW) {
      Serial.println("Button Pressed!");
    }
  }
  lastSwState = swState;
}

// 割り込みハンドラ:回転方向を判定する
void readEncoder() {
  if (digitalRead(PIN_DT) == HIGH) {
    encoderCount++;  // 右回り
  } else {
    encoderCount--;  // 左回り
  }
}

シリアルモニタで確認すると、右に回るとカウントが増え、左に回ると減るはずです。ボタンを押せば「Button Pressed!」と表示されます。

OLEDにメニューリストを表示する

次は、OLEDディスプレイにメニュー項目を表示する処理を組み立てます。

メニュー表示のポイントはこの3つです。

  • 現在のカーソル位置を保持する変数(menuIndex)を使う
  • 選択中の項目の左に「▶」マークを表示する
  • 画面に収まるよう表示範囲をスクロールさせる(スクロールオフセット)
// OLEDにメニューを表示するサンプル

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET    -1   // リセットピンなし

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// メニュー項目
const char* menuItems[] = {
  "1. Brightness",
  "2. Speed",
  "3. Mode",
  "4. Timer",
  "5. Reset"
};
const int MENU_COUNT = 5;
const int LINES_PER_PAGE = 4;  // 1画面に表示できる行数

int menuIndex  = 0;   // 現在のカーソル位置
int scrollOffset = 0; // 表示スクロールのオフセット

void setup() {
  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    // I2Cアドレスは通常0x3Cか0x3D
    for (;;);  // 初期化失敗時は止める
  }
  display.clearDisplay();
  drawMenu();
}

void loop() {
  // エンコーダー読み取りはここに追加(後述)
}

// メニューを描画する関数
void drawMenu() {
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);

  for (int i = 0; i < LINES_PER_PAGE; i++) {
    int itemIndex = i + scrollOffset;
    if (itemIndex >= MENU_COUNT) break;

    int y = i * 16;  // 行の縦位置(16pxずつ)

    if (itemIndex == menuIndex) {
      // 選択中は背景を白・文字を黒で反転表示
      display.fillRect(0, y, SCREEN_WIDTH, 15, SSD1306_WHITE);
      display.setTextColor(SSD1306_BLACK);
    } else {
      display.setTextColor(SSD1306_WHITE);
    }

    display.setCursor(4, y + 4);
    display.print(menuItems[itemIndex]);
  }

  display.display();
}

選択中の行を反転表示(白背景+黒文字)にすることで、どこにカーソルがあるか一目でわかります。これだけでかなりUIらしくなりますよ 😊

エンコーダーとメニューを統合する


ここからが本番です。エンコーダーの回転でカーソルを動かし、ボタンで項目を選択する完成形を組み上げます。

スクロールオフセットの更新ロジックがポイントです。カーソルが画面下端を超えそうになったらoffsetを増やし、上端を切ったらoffsetを減らします。

// ロータリーエンコーダー+OLED メニュー完成スケッチ

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define PIN_CLK 2
#define PIN_DT  3
#define PIN_SW  4

#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT 64
#define OLED_RESET    -1

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

const char* menuItems[] = {
  "1. Brightness",
  "2. Speed",
  "3. Mode",
  "4. Timer",
  "5. Reset"
};
const int MENU_COUNT   = 5;
const int LINES_PER_PAGE = 4;

volatile int  encoderDelta = 0;  // 回転量の差分
bool lastSwState = HIGH;

int menuIndex    = 0;
int scrollOffset = 0;
bool selected    = false;  // 項目が選択されたフラグ

void setup() {
  Serial.begin(9600);
  pinMode(PIN_CLK, INPUT);
  pinMode(PIN_DT,  INPUT);
  pinMode(PIN_SW,  INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(PIN_CLK), readEncoder, FALLING);

  if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
    for (;;);
  }
  display.clearDisplay();
  drawMenu();
}

void loop() {
  // ---- 回転処理 ----
  if (encoderDelta != 0) {
    menuIndex += encoderDelta;
    encoderDelta = 0;

    // 上下端を超えないようにクランプ
    if (menuIndex < 0)           menuIndex = 0;
    if (menuIndex >= MENU_COUNT) menuIndex = MENU_COUNT - 1;

    // スクロールオフセットを更新
    if (menuIndex < scrollOffset) {
      scrollOffset = menuIndex;
    } else if (menuIndex >= scrollOffset + LINES_PER_PAGE) {
      scrollOffset = menuIndex - LINES_PER_PAGE + 1;
    }

    drawMenu();
  }

  // ---- ボタン処理 ----
  bool swState = digitalRead(PIN_SW);
  if (swState == LOW && lastSwState == HIGH) {
    delay(10);
    if (digitalRead(PIN_SW) == LOW) {
      // 選択した項目をシリアルに出力(次のステップで画面遷移に使う)
      Serial.print("Selected: ");
      Serial.println(menuItems[menuIndex]);
      selected = true;
    }
  }
  lastSwState = swState;

  // 選択後の処理(ここでは0.5秒後にメニューに戻る)
  if (selected) {
    showSelectedScreen();
    delay(500);
    selected = false;
    drawMenu();
  }
}

void readEncoder() {
  encoderDelta += (digitalRead(PIN_DT) == HIGH) ? 1 : -1;
}

void drawMenu() {
  display.clearDisplay();
  display.setTextSize(1);

  for (int i = 0; i < LINES_PER_PAGE; i++) {
    int itemIndex = i + scrollOffset;
    if (itemIndex >= MENU_COUNT) break;

    int y = i * 16;
    if (itemIndex == menuIndex) {
      display.fillRect(0, y, SCREEN_WIDTH, 15, SSD1306_WHITE);
      display.setTextColor(SSD1306_BLACK);
    } else {
      display.setTextColor(SSD1306_WHITE);
    }
    display.setCursor(4, y + 4);
    display.print(menuItems[itemIndex]);
  }
  display.display();
}

// 選択後に一時表示するサブ画面
void showSelectedScreen() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.println("Selected:");
  display.setTextSize(2);
  display.setCursor(0, 20);
  // 長い文字列は切り詰めて表示
  String label = String(menuItems[menuIndex]);
  display.println(label.substring(3));  // "1. "の部分を除いて表示
  display.display();
}

これを書き込んでつまみを回すと、OLEDのメニューリストがスクロールしながらカーソルが動くはずです!ぜひ実際に試してみてください。

値を変更できる設定画面を追加する

メニューで項目を選んだあと、数値を増減できるサブ画面に遷移させましょう。

考え方はシンプルです。

  • 状態変数(state)を使ってメニュー画面とサブ画面を切り替える
  • サブ画面ではエンコーダーの回転が「値の増減」として機能する
  • ボタン長押し(または2度押し)で「戻る」動作にする
// 状態管理を使ったサブ画面(値設定画面)の実装

// ---- 状態定義 ----
enum AppState {
  STATE_MENU,    // メニュー画面
  STATE_SETTING  // 設定(値変更)画面
};

AppState currentState = STATE_MENU;

// 各項目の設定値(初期値)
int settingValues[] = {50, 10, 1, 30, 0};  // Brightness, Speed, Mode, Timer, Reset
const int VALUE_MIN[] = { 0,  1, 1,  1, 0};
const int VALUE_MAX[] = {100, 20, 3, 60, 0};

// loop()内の処理をstateで分岐させる
void loopWithState() {
  if (currentState == STATE_MENU) {
    // --- メニュー状態 ---
    if (encoderDelta != 0) {
      menuIndex += encoderDelta;
      encoderDelta = 0;
      menuIndex = constrain(menuIndex, 0, MENU_COUNT - 1);
      if (menuIndex < scrollOffset)
        scrollOffset = menuIndex;
      else if (menuIndex >= scrollOffset + LINES_PER_PAGE)
        scrollOffset = menuIndex - LINES_PER_PAGE + 1;
      drawMenu();
    }

    // ボタン → 設定画面へ遷移
    if (buttonPressed()) {
      currentState = STATE_SETTING;
      drawSettingScreen();
    }

  } else if (currentState == STATE_SETTING) {
    // --- 設定状態 ---
    if (encoderDelta != 0) {
      settingValues[menuIndex] += encoderDelta;
      encoderDelta = 0;
      settingValues[menuIndex] = constrain(
        settingValues[menuIndex],
        VALUE_MIN[menuIndex],
        VALUE_MAX[menuIndex]
      );
      drawSettingScreen();
    }

    // ボタン → メニューに戻る
    if (buttonPressed()) {
      currentState = STATE_MENU;
      drawMenu();
    }
  }
}

// ボタン押下を返すヘルパー関数
bool buttonPressed() {
  static bool lastSw = HIGH;
  bool sw = digitalRead(PIN_SW);
  bool pressed = false;
  if (sw == LOW && lastSw == HIGH) {
    delay(10);
    if (digitalRead(PIN_SW) == LOW) pressed = true;
  }
  lastSw = sw;
  return pressed;
}

// 設定画面の描画
void drawSettingScreen() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  // タイトル行(選択された項目名)
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.println(menuItems[menuIndex]);
  display.drawLine(0, 12, SCREEN_WIDTH, 12, SSD1306_WHITE);  // 区切り線

  // 現在値を大きく表示
  display.setTextSize(3);
  display.setCursor(30, 22);
  display.println(settingValues[menuIndex]);

  // 下部に操作ガイド
  display.setTextSize(1);
  display.setCursor(0, 56);
  display.print("[Turn] Change  [Push] Back");

  display.display();
}

この状態変数(AppState)で画面を切り替える設計はとても応用が効きます。画面が増えても state の種類を追加するだけで対応できるので、ぜひ覚えておいてください。

チャタリング対策のポイント


実際に動かしてみると「回してないのにカーソルが飛ぶ」「1回押したのに2回反応した」といった問題が起きることがあります。これがチャタリングです。

対策としてよく使われるのが以下の3つです。

  • ボタン:delay(10〜20ms)で待つ——押下後に少し待ってから再確認する(上記コードで実装済み)
  • エンコーダー:前回のCLK状態と比較する——割り込みタイミングだけで判定する方法が有効
  • ハードウェア:104(0.1μF)セラミックコンデンサーをCLK・DT・SWとGND間に挟む——ノイズをハード側でカット

KY-040モジュールにはすでにプルアップ抵抗が内蔵されているものが多いですが、コンデンサーを追加するとさらに安定します。

まとめ

今回はロータリーエンコーダー+OLEDディスプレイを組み合わせて、Arduinoでメニュー型のUIを作る方法を解説しました。

ポイントを振り返ると:

  • ロータリーエンコーダーは割り込み(attachInterrupt)で読み取るとスムーズ
  • OLEDのカーソル移動は反転表示+スクロールオフセットで実現できる
  • 状態変数(enum + switch)を使うと画面遷移が管理しやすい
  • チャタリング対策を忘れずに!

この仕組みを応用すれば、温度設定や動作モード選択など、実用的な組み込みUIが作れるようになります。「Arduinoって意外とちゃんとしたUI作れるんだ」と感じてもらえたら嬉しいです 😊

ぜひブレッドボード上で試してみてください。コードが動いたときの達成感、最高ですよ!

📡 Arduinoをもっと深く学ぼう!

Arduino・ラズパイ・ロボットプログラミングを体系的に学びたい方へ。おすすめのUdemyコースや電子部品もまとめています。

Arduinoロボット入門のおすすめコース・部品を見る →

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る

Arduinoをはじめよう 第4版 (Make: PROJECTS)

もしも

Arduinoをはじめよう 第4版 (Make: PROJECTS)

Arduino公式推薦の定番入門書

Amazonで見る

徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

もしも

徹底攻略! 電子工作&プログラミング Arduinoで学ぶ電子工作完全ガイド

電子工作とプログラミングを同時に学べる

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。