前言
阅读此教程需要一定的C++和JUCE基础,如果没有可以翻阅我的其他文章和视频教程。
核心内容为UI设计,Slider样式的高阶设计技巧。
Github原地址:https://github.com/szkkng/ModernDial
我的Bilibili频道:香芋派Taro
我的公众号:香芋派的烘焙坊
我的音频技术交流群:1136403177
我的个人微信:JazzyTaroPie
%EF%BC%81/16418310459248.gif)
准备
打开Projucer并创建一个新项目。因为这篇教程只涉及到UI部分,所以我们选择”GUI”模版即可。
%EF%BC%81/16418311952537.png)
确保这些文件都在Source目录下:
%EF%BC%81/16418311857250.png)
至此所有的准备都已完成,让我们开始吧!
旋钮
在这个章节中,我们会设计这个旋钮的基础部分。
自定义Slider
首先,让我们准备一个从Slider class继承的Dial class,同时override一些函数。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 
 | #pragma once
 #include <JuceHeader.h>
 
 class Dial  : public juce::Slider
 {
 public:
 Dial();
 ~Dial();
 
 void mouseDown (const juce::MouseEvent& event) override;
 void mouseUp (const juce::MouseEvent& event) override;
 
 private:
 JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Dial)
 };
 在下方的构造函数中,许多函数被调用了,但我只会解释其中的一部分。
 Dial::Dial()
 {
 setSliderStyle (juce::Slider::SliderStyle::RotaryVerticalDrag);
 setTextBoxStyle (juce::Slider::TextBoxBelow, true, 80, 20);
 setRotaryParameters (juce::MathConstants<float>::pi * 1.25f,
 juce::MathConstants<float>::pi * 2.75f,
 true);
 setVelocityBasedMode (true);
 setVelocityModeParameters (0.5, 1, 0.09, false);
 setRange (0.0, 100.0, 0.01);
 setValue (50.0);
 setDoubleClickReturnValue (true, 50.0);
 setTextValueSuffix (" %");
 onValueChange = [&]()
 {
 if (getValue() < 10)
 setNumDecimalPlacesToDisplay (2);
 else if (10 <= getValue() && getValue() < 100)
 setNumDecimalPlacesToDisplay (1);
 else
 setNumDecimalPlacesToDisplay (0);
 };
 }
 
 Dial::~Dial()
 {
 }
 
 | 
如果你对其中setRotaryParameters()这个函数比较疑惑的话,可以点击链接查看以下这篇文章,十分有用。
https://theaudioprogrammer.com/customizing-audio-plug-in-interfaces-with-juce-pt-2-creating-an-ableton-style-dial/  
setVelocityBasedMode()
如果 setVelocityBasedMode() 被设置为true,不止是鼠标在被拖拽时会消失,同时还会根据鼠标的移动速度来更改值变化的速度,更加符合直觉。为了能更清晰地看清两者的区别,这里附上关闭和打开的对比图片。
%EF%BC%81/16418312099197.gif)
False
  
%EF%BC%81/16418312187076.gif)
True  
由于其中有很多感知元素,这里建议自己尝试去修改ture和false来体验一下二者区别。 
onValueChange
其中还有一个lambda表达式叫做 onValueChange ,当slider的值变化的时候会被执行。这个函数确保了slider在任何值时其数值都会被正常显示,具体差别如下:
%EF%BC%81/16418312342952.gif)
Before
%EF%BC%81/16418310459248.gif)
After
正如你看到的那样,因为数字的宽度并没有发生太大的改变,所以他看上去很美观。  
mouseDown() mouseUp()
接下来,我会解释 mouseDown() 和 mouseUp() 两个函数
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 
 | void Dial::mouseDown (const juce::MouseEvent& event){
 juce::Slider::mouseDown (event);
 
 setMouseCursor (juce::MouseCursor::NoCursor);
 }
 
 void Dial::mouseUp (const juce::MouseEvent& event)
 {
 juce::Slider::mouseUp (event);
 
 juce::Desktop::getInstance().getMainMouseSource().setScreenPosition (event.source.getLastMouseDownPosition());
 
 setMouseCursor (juce::MouseCursor::NormalCursor);
 }
 
 | 
你可能会好奇为什么我还要再设计一个处理来在velocity模式被打开时隐藏鼠标,因为我想让鼠标在点击的瞬间被隐藏。如果仅仅只打开这个模式,那么在你的鼠标点击时光标不会被隐藏,而是在你开始拖动时才会被隐藏。
mouseUp() 中的函数确保了当鼠标释放时,光标将会回到点击瞬间时的位置。这可能看上去没有太大的差别,但这个小细节就提升了用户的使用体验。
创建旋钮对象
让我们引入头文件并开始准备三个旋钮对象。首先我们先定义他们的颜色。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 
 | #pragma once
 #include <JuceHeader.h>
 #include "Dial.h"
 
 class MainComponent  : public juce::Component
 {
 public:
 
 MainComponent();
 ~MainComponent();
 
 
 void paint (juce::Graphics&) override;
 void resized() override;
 
 private:
 
 Dial blueDial, yellowDial, greenDial;
 
 juce::Colour blue   = juce::Colour::fromFloatRGBA (0.43f, 0.83f, 1.0f,  1.0f);
 juce::Colour green  = juce::Colour::fromFloatRGBA (0.34f, 0.74f, 0.66f, 1.0f);
 juce::Colour yellow = juce::Colour::fromFloatRGBA (1.0f,  0.71f, 0.2f,  1.0f);
 juce::Colour black  = juce::Colour::fromFloatRGBA (0.08f, 0.08f, 0.08f, 1.0f);
 
 JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent)
 };
 
 | 
定义的部分如下,因为并不难,所以我会跳过这些内容的解释。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 
 | #include "MainComponent.h"
 
 MainComponent::MainComponent()
 {
 setSize (600, 400);
 
 blueDial.setColour (juce::Slider::rotarySliderFillColourId, blue);
 greenDial.setColour (juce::Slider::rotarySliderFillColourId, green);
 yellowDial.setColour (juce::Slider::rotarySliderFillColourId, yellow);
 
 addAndMakeVisible (blueDial);
 addAndMakeVisible (greenDial);
 addAndMakeVisible (yellowDial);
 }
 
 MainComponent::~MainComponent()
 {
 }
 
 
 void MainComponent::paint (juce::Graphics& g)
 {
 g.fillAll (black) ;
 }
 
 void MainComponent::resized()
 {
 blueDial.setBounds (120, 160, 80, 80);
 greenDial.setBounds (260, 160, 80, 80);
 yellowDial.setBounds (400, 160, 80, 80);
 }
 
 | 
运行
现在旋钮的基础已经完成,让我们试着运行一下吧!
%EF%BC%81/16418312722965.png)
LookAndFeel
在这个章节中,我们会自定义LookAndFeel类来继续完善这个旋钮。
自定义LookAndFeel
头文件的内容如下,我们根据slider的描述override三个成员函数。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 
 | #pragma once
 #include <JuceHeader.h>
 
 class CustomLookAndFeel : public juce::LookAndFeel_V4
 {
 public:
 CustomLookAndFeel();
 ~CustomLookAndFeel();
 
 juce::Slider::SliderLayout getSliderLayout (juce::Slider& slider) override;
 
 void drawRotarySlider (juce::Graphics&, int x, int y, int width, int height,
 float sliderPosProportional, float rotaryStartAngle,
 float rotaryEndAngle, juce::Slider&) override;
 
 juce::Label* createSliderTextBox (juce::Slider& slider) override;
 
 private:
 juce::Colour blue      = juce::Colour::fromFloatRGBA (0.43f, 0.83f, 1.0f,  1.0f);
 juce::Colour offWhite  = juce::Colour::fromFloatRGBA (0.83f, 0.84f, 0.9f,  1.0f);
 juce::Colour grey      = juce::Colour::fromFloatRGBA (0.42f, 0.42f, 0.42f, 1.0f);
 juce::Colour blackGrey = juce::Colour::fromFloatRGBA (0.2f,  0.2f,  0.2f,  1.0f);
 
 JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (CustomLookAndFeel);
 };
 
 | 
getSliderLayout()
getSliderLayout() 函数定义了旋钮的位置、大小和中央的文本框。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 
 | #include "CustomLookAndFeel.h"
 CustomLookAndFeel::CustomLookAndFeel() {};
 CustomLookAndFeel::~CustomLookAndFeel() {};
 
 juce::Slider::SliderLayout CustomLookAndFeel::getSliderLayout (juce::Slider& slider)
 {
 auto localBounds = slider.getLocalBounds();
 
 juce::Slider::SliderLayout layout;
 
 layout.textBoxBounds = localBounds;
 layout.sliderBounds = localBounds;
 
 return layout;
 }
 
 | 
drawRotarySlider()
drawRotarySlider() 函数对这个旋钮的影响很大。他确保了在旋钮被缩放时不会变形,绘制了旋钮背后正方形的四个角,同时让旋钮的角度与数值相关联。
现在让我们override createsSliderTextBox()这个函数,它的功能是使旋钮中央的文本框显示当前旋钮的值。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 
 | juce::Label* CustomLookAndFeel::createSliderTextBox (juce::Slider& slider){
 auto* l = new juce::Label();
 
 l->setFont (17.0f);
 l->setJustificationType (juce::Justification::centred);
 l->setColour (juce::Label::textColourId, slider.findColour (juce::Slider::textBoxTextColourId));
 l->setColour (juce::Label::textWhenEditingColourId, slider.findColour (juce::Slider::textBoxTextColourId));
 l->setColour (juce::Label::outlineWhenEditingColourId, slider.findColour (juce::Slider::textBoxOutlineColourId));
 l->setInterceptsMouseClicks (false, false);
 
 return l;
 }
 
 | 
setInterceptMouseClicks()
setInterceptMouseClicks() 是一个非常重要的函数,如果这个函数没有被设定为false,那么你将无法在文本框上拖拽,如果此时文本框在旋钮中央的话,这会是一个致命的问题。
%EF%BC%81/16418312841561.gif)
True
%EF%BC%81/16418312898131.gif)
False
创建CustomLookAndFeel对象
CustomLookAndFeel将会被应用至旋钮对象,所以在Dial.h中引入这个头文件来准备创建对象。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 
 | #pragma once
 #include <JuceHeader.h>
 #include "CustomLookAndFeel.h"
 
 class Dial  : public juce::Slider
 {
 public:
 ・・・
 private:
 CustomLookAndFeel customLookAndFeel;
 
 juce::Colour grey      = juce::Colour::fromFloatRGBA (0.42f, 0.42f, 0.42f, 1.0f);
 juce::Colour blackGrey = juce::Colour::fromFloatRGBA (0.2f,  0.2f,  0.2f, 1.0f);
 ・・・
 
 | 
为了将LookAndFeel应用至旋钮对象,需要调用setLookAndFeel()函数。同时,设定好文本框的颜色并为接下来的focus mark做好准备(focus mark就是把鼠标移到旋钮上四周会出现的那个矩形框)。
| 12
 3
 4
 5
 6
 7
 
 | Dial::Dial(){
 ・・・
 setColour (juce::Slider::textBoxTextColourId, blackGrey);
 setColour (juce::Slider::textBoxOutlineColourId, grey);
 setLookAndFeel (&customLookAndFeel);
 }
 
 | 
运行
%EF%BC%81/16418312978246.png)
Focus Mark
在这个章节中,我们会设计Focus Mark。
Overriding paint()
override paint() 来让它描述这个标记
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 
 | class Dial  : public juce::Slider{
 public:
 Dial();
 ~Dial();
 
 void paint (juce::Graphics& g) override;
 
 void mouseDown (const juce::MouseEvent& event) override;
 void mouseUp (const juce::MouseEvent& event) override;
 
 private:
 ・・・
 
 | 
定义部分如下:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 
 | void Dial::paint (juce::Graphics& g){
 juce::Slider::paint (g);
 
 if (hasKeyboardFocus (false))
 {
 auto bounds = getLocalBounds().toFloat();
 auto h = bounds.getHeight();
 auto w = bounds.getWidth();
 auto len = juce::jmin (h, w) * 0.07f;
 auto thick  = len / 1.8f;
 
 g.setColour (findColour (juce::Slider::textBoxOutlineColourId));
 
 
 g.drawLine (0.0f, 0.0f, 0.0f, len, thick);
 g.drawLine (0.0f, 0.0f, len, 0.0f, thick);
 
 
 g.drawLine (0.0f, h, 0.0f, h - len, thick);
 g.drawLine (0.0f, h, len, h, thick);
 
 
 g.drawLine (w, 0.0f, w, len, thick);
 g.drawLine (w, 0.0f, w - len, 0.0f, thick);
 
 
 g.drawLine (w, h, w, h - len, thick);
 g.drawLine (w, h, w - len, h, thick);
 }
 }
 
 | 
当鼠标被移动到旋钮上时,hasKeyboardFocus() 会返回true。
此外,在focus时下面这个函数必须被调用。
| 12
 3
 4
 5
 
 | Dial::Dial(){
 ・・・
 setWantsKeyboardFocus (true);
 }
 
 | 
现在尝试点击和拖拽每一个旋钮,当鼠标点击到不同的旋钮时,周围的focus mark也会相应变化。
%EF%BC%81/16418313161463.png)
最后,因为此时除非点击其他旋钮,否则focus mark不会消失。为了解决这个问题,setWantsKetboardFocus()必须在下面这个构造函数中被调用。
| 12
 3
 4
 5
 6
 
 | MainComponent::MainComponent(){
 setSize (600, 400);
 setWantsKeyboardFocus (true);
 ・・・
 }
 
 | 
总结
以上就是所有的关于这个旋钮的设计,最终效果如下:
![](164s mark也会同步消失。
在这篇教程中,我们讲解了如何去设计一个modern dial。如果你有任何改进和建议,欢迎评论和交流!感谢您能阅读到这里!