MOD-D3のUIをJUCEで再現する② : xyz mapボタンとThreeValueなスライダー

概要

前回に引き続き、今回もMOD-D3のUIを実装していきます。

前回は新規プロジェクトの作成から始め、下記UIを作成するところまでやりました。

当記事では、下記UIまで実装します。スライダーの見た目に関してはだいぶ本家に近づいてきました。

前回と比べ、新しく実装した機能は以下の通りです。

  • xyz mapボタンの作成
  • 各mapボタンのon/offによるTwoValueとThreeValueの切り替え
  • 二色のThreeValueに対応したMyLookAndFeel

それでは上記UIを作成する手順と解説をまとめていきます。

環境

  • macOS Big Sur 11.2.3
  • JUCE v6.0.8

xyz mapボタンの作成

まずは各スライダーの上部にあるxyz mapボタンを作成していきます。

このボタンは後述するThreeValueとTwoValueの切り替えに使用します。とりあえずこの章では、基本的なボタンの作成と配置だけしていきます。

TextButtonオブジェクトの宣言

まずはx,、y、z用のTextButtonオブジェクトを三つ宣言します。

class ModD3AudioProcessorEditor  : public juce::AudioProcessorEditor, public juce::Slider::Listener
{
・・・
private:
・・・
    juce::TextButton xMapButton;
    juce::TextButton yMapButton;
    juce::TextButton zMapButton;

TextButtonオブジェクトの詳細設定

次に各TextButtonオブジェクトに対して、下記メンバ関数を呼び出し、詳細な設定を行います。

ModD3AudioProcessorEditor::ModD3AudioProcessorEditor (ModD3AudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{    
・・・         
    addAndMakeVisible(&xMapButton);
    xMapButton.setButtonText("x.map");
    
    addAndMakeVisible(&yMapButton);
    yMapButton.setButtonText("y.map");
    
    addAndMakeVisible(&zMapButton);
    zMapButton.setButtonText("z.map");

setButtonText()はボタン内に指定したテキストを設定するメンバ関数です。なので各mapボタンの名前を指定します。

配置と大きさの設定

setBounds()は下記のように設定します。また、前回設定したラベルの方も少し変更しています。

 void ModD3AudioProcessorEditor::resized()
{
・・・
    leftLabelModSlider1.setBounds(modSlider1.getWidth() / 2 - 65, modSlider1.getY() + 30, 55, 15);
    rightLabelModSlider1.setBounds(modSlider1.getWidth() / 2 + 15, modSlider1.getY() + 30, 55, 15);
    leftLabelModSlider2.setBounds(modSlider2.getWidth() / 2 - 65, modSlider2.getY() + 30, 55, 15);
    rightLabelModSlider2.setBounds(modSlider2.getWidth() / 2 + 15, modSlider2.getY() + 30, 55, 15);
    leftLabelModSlider3.setBounds(modSlider3.getWidth() / 2 - 65, modSlider3.getY() + 30, 55, 15);
    rightLabelModSlider3.setBounds(modSlider3.getWidth() / 2 + 15, modSlider3.getY() + 30, 55, 15);
    
    xMapButton.setBounds(15, modSlider1.getY() - 25, getWidth() - 30, 25);
    yMapButton.setBounds(15, modSlider2.getY() - 25, getWidth() - 30, 25);
    zMapButton.setBounds(15, modSlider3.getY() - 25, getWidth() - 30, 25);
}

xyz mapボタンを作成する最低限の設定が完了したので、ビルドをして確認します。

いかにもデフォルトのボタンっていう感じがします。次の章でボタンの外観を整えていきます。

xyz mapボタンの外観設定

この章では、xyz mapボタンの外観を本家に近づけます。

具体的には、mapボタンの外枠の線をカクカクにしたり、mapボタンのon/offで色が変わるようにします。

MyLookAndFeel()の実装

まずは自作したMyLookAndFeelクラスの下記コンストラクタに、ボタンのon/off時の背景色とテキスト色を設定します。

class MyLookAndFeel : public juce::LookAndFeel_V4
{
public
    MyLookAndFeel ()
    {
        setColour(juce::TextButton::buttonColourId, juce::Colours::black.brighter(0.09));
        setColour(juce::TextButton::buttonOnColourId, juce::Colours::black);
        setColour(juce::TextButton::textColourOffId, juce::Colours::grey);
        setColour(juce::TextButton::textColourOnId, juce::Colours::white);
    }

buttonColourIdtextColourOffIdは、それぞれボタンoff時の背景色とテキスト色を設定するColourIdです。

それに対し、buttonOnColourIdtextColourOnIdは、ボタンon時の背景色とテキスト色を設定するためのColourIdです。

drawButtonBackground()のオーバーライド

次に、下記のようにdrawButtonBackground()をオーバーライドします。

class MyLookAndFeel : public juce::LookAndFeel_V4
{
・・・
    void drawButtonBackground (juce::Graphics& g,
                               juce::Button& button,
                               const juce::Colour& backgroundColour,
                               bool shouldDrawButtonAsHighlighted,
                               bool shouldDrawButtonAsDown) override
    {
        auto bounds = button.getLocalBounds().toFloat().reduced (0.5f, 0.5f);
        
        g.setColour (backgroundColour);
        juce::Path path;
        path.addRectangle (bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight());
        g.fillPath (path);
        
        g.setColour (juce::Colours::grey);
        g.strokePath (path, juce::PathStrokeType (1.0f));
    }
};

名前からも分かる通り、このメンバ関数はボタンの背景部分を描写するものです。LookAndFeel_V4クラスで定義されていたdrawButtonBackground()は、外枠の線を丸くさせていたり、ボタンのマウスホバー機能が実装されていたので、そこを主にカスタマイズしました。

つまり、外枠の線をカクカクにさせ、マウスホバーで色が変わらないようにしました。

myLookAndFeelの適用

各mapボタンに対して、下記のようにmyLookAndFeelを適用させます。

ModD3AudioProcessorEditor::ModD3AudioProcessorEditor (ModD3AudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{
・・・
    addAndMakeVisible(&xMapButton);
    xMapButton.setLookAndFeel(&myLookAndFeel);
    xMapButton.setButtonText("x.map");
    
    addAndMakeVisible(&yMapButton);
    yMapButton.setLookAndFeel(&myLookAndFeel);
    yMapButton.setButtonText("y.map");
    
    addAndMakeVisible(&zMapButton);
    zMapButton.setLookAndFeel(&myLookAndFeel);
    zMapButton.setButtonText("z.map");

現時点でビルドをすると下記のようになります。

詳しいことは後述しますが、各ボタンのデフォルトはoff状態なので、buttonColourIdtextColourOffIdの方が適用されています。

ボタンのon/offによる外観の切り替え

現時点のボタンは、クリックしてもon/offできませんし、もちろん外観も変わりません。

なので、ボタンをクリックした時に外観が切り替わるようにします。

リスナークラスの継承

まず、下記のようにButton用のリスナークラスを継承するように設定し、各mapボタンにリスナーを追加します。

class ModD3AudioProcessorEditor  : public juce::AudioProcessorEditor, public juce::Slider::Listener, public juce::Button::Listener
{
・・・
ModD3AudioProcessorEditor::ModD3AudioProcessorEditor (ModD3AudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{
・・・
    addAndMakeVisible(&xMapButton);
    xMapButton.setLookAndFeel(&myLookAndFeel);
    xMapButton.setButtonText("x.map");
    xMapButton.addListener(this);
    
    addAndMakeVisible(&yMapButton);
    yMapButton.setLookAndFeel(&myLookAndFeel);
    yMapButton.setButtonText("y.map");
    yMapButton.addListener(this);
    
    addAndMakeVisible(&zMapButton);
    zMapButton.setLookAndFeel(&myLookAndFeel);
    zMapButton.setButtonText("z.map");
    zMapButton.addListener(this);

buttonClicked()のオーバーライド

次にbuttonClicked()をオーバライドします。このメンバ関数は、ボタンがクリックされた時に実行されるものです。

class ModD3AudioProcessorEditor  : public juce::AudioProcessorEditor, public juce::Slider::Listener, public juce::Button::Listener
{
public:
・・・
    void buttonClicked(juce::Button* button) override;
void ModD3AudioProcessorEditor::buttonClicked(juce::Button* button)
{
    if (button == &xMapButton)
    {
        if (!xMapButton.getToggleState())
        {
            xMapButton.setToggleState(true, juce::NotificationType::dontSendNotification);
        }
        else
        {
            xMapButton.setToggleState(false, juce::NotificationType::dontSendNotification);
        }
    }
    if (button == &yMapButton)
    {
        if (!yMapButton.getToggleState())
        {
            yMapButton.setToggleState(true, juce::NotificationType::dontSendNotification);
        }
        else
        {
            yMapButton.setToggleState(false, juce::NotificationType::dontSendNotification);
        }
    }
    if (button == &zMapButton)
    {
        if (!zMapButton.getToggleState())
        {
            zMapButton.setToggleState(true, juce::NotificationType::dontSendNotification);
        }
        else
        {
            zMapButton.setToggleState(false, juce::NotificationType::dontSendNotification);
        }
    }
}

上記実装部分で重要なのは、getToggleState()setToggleState()です。

前者はボタンのon/offの状態を取得するメンバ関数、後者はボタンのon/offの状態を変更するメンバ関数です。

つまり処理の流れは以下のようになります。

  1. ボタンクリック時にbottunClicked()が呼び出される
  2. クリックされたボタンの状態を取得
  3. もしoff状態だったらon状態に変更、on状態だったらoff状態に変更

TwoValueとThreeValueの切り替え

この章では、mapをクリックした時に、TwoValueとThreeValueが切り替わるようにしていきます。

また、MOD-D3は小さいThumbと大きいThumbの配置が逆になると、黄色から青色へと変化するので、この部分も実装していきます。

drawLinearSlider()の変更

まずはMyLookAndFeelのdrawLinearSlider()をThreeValueに対応させる必要があります。なぜなら前回、TwoValueだけに対応するように実装してしまったからです。

下記はThreeValueに対応させたdrawLinearSlider()の実装部分です。

class MyLookAndFeel : public juce::LookAndFeel_V4
{
・・・
void drawLinearSlider (juce::Graphics& g, int x, int y, int width, int height,
                                           float sliderPos,
                                           float minSliderPos,
                                           float maxSliderPos,
                                           const juce::Slider::SliderStyle style, juce::Slider& slider) override
    {
        auto isThreeVal = (style == juce::Slider::SliderStyle::ThreeValueHorizontal);       
        auto trackWidth = fmin (2.0f, (float) height * 0.25f);

        juce::Point<float> startPoint ((float) x, (float) y + (float) height * 0.5f);
        juce::Point<float> endPoint ((float) (width + x), startPoint.y);

        juce::Path backgroundTrack;
        backgroundTrack.startNewSubPath (startPoint);
        backgroundTrack.lineTo (endPoint);
        g.setColour (juce::Colours::grey.darker(1.0));
        juce::PathStrokeType pathStrokeType(0.8);
        float dashedLength[2] = {2, 4};
        pathStrokeType.createDashedStroke(backgroundTrack, backgroundTrack, dashedLength, 2);
        g.strokePath(backgroundTrack, pathStrokeType);

        juce::Path valueTrack;
        juce::Point<float> minPoint, maxPoint, thumbPoint;        
        minPoint = { minSliderPos, (float) height * 0.5f };
        maxPoint = { maxSliderPos, (float) height * 0.5f };
        thumbPoint = { sliderPos, (float) height * 0.5f };                                
        auto minThumbWidth = getSliderThumbRadius (slider) - 22;
        auto maxThumbWidth = getSliderThumbRadius (slider) - 26;
        auto thumbWidth = getSliderThumbRadius (slider) - 19;
                        
        valueTrack.startNewSubPath (minPoint);
        valueTrack.lineTo (maxPoint);
        g.setColour (juce::Colours::grey);
        g.strokePath (valueTrack, { trackWidth, juce::PathStrokeType::curved, juce::PathStrokeType::rounded });                
        

        int thumbBeingDragged = slider.getThumbBeingDragged();
                
        if (thumbBeingDragged == 1 || thumbBeingDragged == 2)
        {
            slider.setMouseCursor(juce::MouseCursor::NoCursor);
            
            g.setColour (juce::Colours::white.darker(0.05));
            g.drawRoundedRectangle (juce::Rectangle<float>(static_cast<float> (thumbBeingDragged == 1 ? maxThumbWidth : minThumbWidth), static_cast<float> (thumbBeingDragged == 1 ? maxThumbWidth : minThumbWidth)).withCentre(thumbBeingDragged == 1 ? maxPoint : minPoint), 50, 1.0);
            
            g.setColour (juce::Colours::grey);
            g.fillEllipse (juce::Rectangle<float> (static_cast<float> (thumbBeingDragged == 1 ? minThumbWidth : maxThumbWidth), static_cast<float> (thumbBeingDragged == 1 ? minThumbWidth : maxThumbWidth)).withCentre (thumbBeingDragged == 1 ? minPoint : maxPoint));
        }
        else
        {
            slider.setMouseCursor(juce::MouseCursor::NormalCursor);
            g.setColour (juce::Colours::white.darker(0.05));
            g.drawRoundedRectangle (juce::Rectangle<float>(static_cast<float> (maxThumbWidth), static_cast<float> (maxThumbWidth)).withCentre(maxPoint), 50, 1.0);
            g.drawRoundedRectangle (juce::Rectangle<float>(static_cast<float> (minThumbWidth), static_cast<float> (minThumbWidth)).withCentre(minPoint), 50, 1.0);
        }
        
        if (isThreeVal)
        {
            g.setColour (juce::Colours::yellow.darker(0.1));
            g.fillEllipse (juce::Rectangle<float> (static_cast<float> (thumbWidth), static_cast<float> (thumbWidth)).withCentre (thumbPoint));
            
            if (minSliderPos >= maxSliderPos)
            {
                g.setColour (juce::Colours::cyan.darker(0.1));
                g.fillEllipse (juce::Rectangle<float> (static_cast<float> (thumbWidth), static_cast<float> (thumbWidth)).withCentre (thumbPoint));
            }
        }
    }

特に重要なのは下記実装部分です。

if (isThreeVal)
        {
            g.setColour (juce::Colours::yellow.darker(0.1));
            g.fillEllipse (juce::Rectangle<float> (static_cast<float> (thumbWidth), static_cast<float> (thumbWidth)).withCentre (thumbPoint));
            
            if (minSliderPos >= maxSliderPos)
            {
                g.setColour (juce::Colours::cyan.darker(0.1));
                g.fillEllipse (juce::Rectangle<float> (static_cast<float> (thumbWidth), static_cast<float> (thumbWidth)).withCentre (thumbPoint));
            }
        }

まず、スライダーがThreeValueの時は、黄色のThumbで表示するようにします。また、小さいThumbが大きいThumbの位置以上に移動すると、水色のThumbへと変化するようにしています。

mapボタンとの連携

それではmapボタンと連携させて、TwoValue/ThreeValueの切り替えをできるようにしていきます。

どういう時にThreeValueにしたいかというと、各mapボタンがon状態になっている時です。

そのため、先程のbuttonClicked()内を下記のように変更します。

void ModD3AudioProcessorEditor::buttonClicked(juce::Button* button)
{
    if (button == &xMapButton)
    {
        if (!xMapButton.getToggleState())
        {
            xMapButton.setToggleState(true, juce::NotificationType::dontSendNotification);
            modSlider1.setSliderStyle(juce::Slider::ThreeValueHorizontal);
        }
        else
        {
            xMapButton.setToggleState(false, juce::NotificationType::dontSendNotification);
            modSlider1.setSliderStyle(juce::Slider::TwoValueHorizontal);
        }
    }
    if (button == &yMapButton)
    {
        if (!yMapButton.getToggleState())
        {
            yMapButton.setToggleState(true, juce::NotificationType::dontSendNotification);
            modSlider2.setSliderStyle(juce::Slider::ThreeValueHorizontal);
        }
        else
        {
            yMapButton.setToggleState(false, juce::NotificationType::dontSendNotification);
            modSlider2.setSliderStyle(juce::Slider::TwoValueHorizontal);
        }
    }
    if (button == &zMapButton)
    {
        if (!zMapButton.getToggleState())
        {
            zMapButton.setToggleState(true, juce::NotificationType::dontSendNotification);
            modSlider3.setSliderStyle(juce::Slider::ThreeValueHorizontal);
        }
        else
        {
            zMapButton.setToggleState(false, juce::NotificationType::dontSendNotification);
            modSlider3.setSliderStyle(juce::Slider::TwoValueHorizontal);
        }
    }

現時点でビルドをすると以下のような感じになります。

位置の固定

MOD-D3の三つ目のThumbは、初期状態では小さいThumbと大きいThumbの中間位置に固定されるようになっています。

現時点では、小さいThumbの位置と同じ位置に設定されているため、まずはこの部分を変更していきます。

実装する部分は、先程からよく登場しているbuttonClick()です。下記を追記します。

void ModD3AudioProcessorEditor::buttonClicked(juce::Button* button)
{
    if (button == &xMapButton)
    {
        if (!xMapButton.getToggleState())
        {
            xMapButton.setToggleState(true, juce::NotificationType::dontSendNotification);
            modSlider1.setSliderStyle(juce::Slider::ThreeValueHorizontal);
            modSlider1.setValue((modSlider1.getMinValue() + modSlider1.getMaxValue()) / 2);
        }
        else
        {
            xMapButton.setToggleState(false, juce::NotificationType::dontSendNotification);
            modSlider1.setSliderStyle(juce::Slider::TwoValueHorizontal);
        }
    }
    if (button == &yMapButton)
    {
        if (!yMapButton.getToggleState())
        {
            yMapButton.setToggleState(true, juce::NotificationType::dontSendNotification);
            modSlider2.setSliderStyle(juce::Slider::ThreeValueHorizontal);
            modSlider2.setValue((modSlider2.getMinValue() + modSlider2.getMaxValue()) / 2);
        }
        else
        {
            yMapButton.setToggleState(false, juce::NotificationType::dontSendNotification);
            modSlider2.setSliderStyle(juce::Slider::TwoValueHorizontal);
        }
    }
    if (button == &zMapButton)
    {
        if (!zMapButton.getToggleState())
        {
            zMapButton.setToggleState(true, juce::NotificationType::dontSendNotification);
            modSlider3.setSliderStyle(juce::Slider::ThreeValueHorizontal);
            modSlider3.setValue((modSlider3.getMinValue() + modSlider3.getMaxValue()) / 2);
        }
        else
        {
            zMapButton.setToggleState(false, juce::NotificationType::dontSendNotification);
            modSlider3.setSliderStyle(juce::Slider::TwoValueHorizontal);
        }
    }

これで、ボタンをクリックしてon状態になった時、デフォルトで中間位置にセットされるようになりました。

しかし、現時点では下記のようにThumbを動かすと中間位置からズレてしまいます。

なので、Thumbを動かしてもずっと中間位置に固定されるようにします。sliderValueChanged()内に下記を追記します。

void ModD3AudioProcessorEditor::sliderValueChanged(juce::Slider *slider)
{
    if (slider == &modSlider1)
    {
        leftLabelModSlider1.setText(modSlider1.getTextFromValue(modSlider1.getMinValue()), juce::dontSendNotification);
        rightLabelModSlider1.setText(modSlider1.getTextFromValue(modSlider1.getMaxValue()), juce::dontSendNotification);
        
        if (modSlider1.isThreeValue())
        {
            modSlider1.setValue((modSlider1.getMinValue() + modSlider1.getMaxValue()) / 2);
        }
    }
    
    if (slider == &modSlider2)
    {
        leftLabelModSlider2.setText(modSlider2.getTextFromValue(modSlider2.getMinValue()), juce::dontSendNotification);
        rightLabelModSlider2.setText(modSlider2.getTextFromValue(modSlider2.getMaxValue()), juce::dontSendNotification);
        
        if (modSlider2.isThreeValue())
        {
            modSlider2.setValue((modSlider2.getMinValue() + modSlider2.getMaxValue()) / 2);
        }
    }
    
    if (slider == &modSlider3)
    {
        leftLabelModSlider3.setText(modSlider3.getTextFromValue(modSlider3.getMinValue()), juce::dontSendNotification);
        rightLabelModSlider3.setText(modSlider3.getTextFromValue(modSlider3.getMaxValue()), juce::dontSendNotification);
        
        if (modSlider3.isThreeValue())
        {
            modSlider3.setValue((modSlider3.getMinValue() + modSlider3.getMaxValue()) / 2);
        }
    }
}

これでThumbをどんなに動かしたとしても、中間位置に固定されるようになります。

以上で、当記事の目標である下記UIのできあがりです。

今後の課題

今回はxyz mapボタンの実装とTwoValue/ThreeValueの切り替えの実装についてまとめました。

来週までに実装したいことをまとめると以下のようになります。

  • マウスホバーでThumbの色を変える
  • ラベルの値変更でThumbの値を変える

一つ目は先週からチャレンジしているのですが、今週では実装できませんでした。なかなか苦戦していますが、来週こそは実装できるように頑張っていきます。

Comments

Copied title and URL