Qt實現一個支援QSS的Switch Button(開關按鈕)

eiilpux17發表於2020-12-12

Qt實現一個支援QSS的Switch Button(開關按鈕)

本文會比較長,目的是為了提供一種實現自定義複雜控制元件的方式,對於使用 QSS 應用樣式的專案可能會有幫助。

實現的過程會相對比較複雜和難理解,僅作為研究,對於實際開發可能沒什麼太大價值。

放上最終的實現效果圖:
最終效果

問題

  • 常見的 Switch Button ,至少包含兩部分,槽和滑塊,這種由多個小部件組合的控制元件,在 Qt 內部屬於 Complex Control(複雜控制元件),比如 QComboBox、QSlider。使用樣式表定義各部分子控制元件的樣式,需要使用子控制元件選擇器:
    QProgressBar::chunk {
        background-color: #05B8CC;
        width: 20px;
    }
    
  • Qt 確實沒有開放 QStyleSheetStyle 以及相關的 QSS 解析,所以擴充 QSS 的方式實現自定義複雜控制元件時不可能的。

解決思路

  • 我在 QComboBox文字居中的一種解決辦法 中發現,QStyle 使用該介面繪製控制元件(也有其他類似介面):

    void QStyle::drawControl(QStyle::ControlElement, const QStyleOption *, QPainter *, const QWidget *) 
    

    需要 QStyleOption 和 QWidget , 但 QStyleOption 不需要與 QWidget 的型別對應。比如可以使用 QStyleOptionSlider ,但傳遞 QPushButoon 型別的控制元件,這樣定義在 QPushButoon 上的屬於 QSlider 的樣式同樣可以繪製出來,儘管 QPushButoon 並不支援這些屬性:

    QPushButton{
        color : red;
    }
    QPushButton::handle{
        background: blue;
    }
    

    這樣從側面證明了,QSS 僅僅只是樣式定義的集合,當選擇器匹配到控制元件時,並不關心控制元件型別,只要繪製時對應的 QStyleOption 能獲取到定義的樣式即可,而這些樣式會被覆蓋到 QStyleOption::palette,來實現動態的樣式,這也是為什麼 QWidget::palette() 並不能影響 QSS 的原因。

實現方式

  • 使用 QPushButton 作為基類,將 Switch Button 各部分繪製到按鈕上,這樣可以保留按鈕原生的屬性和訊號。 Switch Button 可以分為兩個部分,槽和滑塊,槽可以使用按鈕背景控制,滑塊作為子控制元件,使用 QSlider 或其子類。
  1. 繪製槽

    定義好樣式,固定高度和圓角,Checked 的偽狀態使用 on(實際 Qt 原始碼在 Checked 時也使用 QStyle::State_On):

    SwitchButton{
        background:#CCCCCC; /*Unchecked背景*/
        border: none;
        border-radius: 15px; /*圓角*/
        height: 30px;
    }
    SwitchButton:on{
        background: #4CCCE6;
    }
    

    重寫QPushButton::paintEvent ,分兩層繪製按鈕背景。

    QStyleOptionButton buttonOpt;
    initStyleOption(&buttonOpt);    // 初始化狀態
    buttonOpt.rect.adjust(0, 0, -1, 0); // 繪製滑塊可能會有一畫素偏差
    buttonOpt.state &= ~QStyle::State_On;   // 先繪製Unchecked時背景
    style()->drawControl(QStyle::CE_PushButtonBevel, &buttonOpt, &painter, this);
     
    painter.setOpacity(progress);   // 定義一個動態漸變值,0~1變化,用透明度動畫控制切換
    
    buttonOpt.state |= QStyle::State_On;    // 繪製Checked時背景
    style()->drawControl(QStyle::CE_PushButtonBevel, &buttonOpt, &painter, this);
    

    由於滑塊滑動過程是個動態過程,背景從 Unchecked 到 Checked 需要切換,這裡為了簡單控制,使用了兩層繪製,所以可能不適合使用帶有透明度的顏色值。其他方式在後面會簡單介紹。

  2. 繪製滑塊

    定義滑塊樣式。Switch Button 的滑塊意義上也可以叫做 handle,所以使用 handle 子控制元件,滑塊高度預設是槽的高度,我這裡使用了 QScrollBar 的樣式,所以需要限制滑塊寬度,也可以使用 QSlider 等。

    SwitchButton::handle{
        background: white;
        border:none;
        min-width:30px;
        max-width:30px;
        border-radius:15px; /*圓角*/
    }
    

    在繪製背景色後繪製滑塊,使用了 QStyleOptionSlider :

    QStyleOptionSlider sliderOpt;
    sliderOpt.init(this);
    sliderOpt.minimum = 0;
    sliderOpt.maximum = sliderOpt.rect.width(); // 直接使用畫素範圍
    int position = int(progress * (sliderOpt.rect.width()));   // 根據動態值控制滑塊範圍
    sliderOpt.sliderPosition = qMin(qMax(position, 0), sliderOpt.maximum); 
    sliderOpt.sliderValue = sliderOpt.sliderPosition;
    
    // 重設滑塊區域,Qt原始碼會這麼做,否則會繪製到整個按鈕上
    sliderOpt.rect = style()->subControlRect(QStyle::CC_ScrollBar, &sliderOpt, QStyle::SC_ScrollBarSlider, this); 
    style()->drawControl(QStyle::CE_ScrollBarSlider, &sliderOpt, &painter, this);   // 繪製滑塊
    
  3. 定義動畫

    最後可以定義個動畫,當狀態切換時觸發動畫,設定 0~1 變化來繪製滑塊位置和背景色的漸變。

    QVariantAnimation *animation = new QVariantAnimation(this);
    animation->setStartValue(0.0);
    animation->setEndValue(1.0);
    animation->setDuration(200);
    connect(animation, &QVariantAnimation::valueChanged, this, [this](const QVariant & val){
        progress = val.toReal();    // progress定義為成員
        update();
    });
    

    按鈕狀態切換為 Checked 時,progress 需要從 0 → 1 變化,所以為正向;狀態切換為Unchecked時,progress從 1 → 0 ,所以反向。快讀點選時需要暫停動畫,重設方向並繼續。

    QAbstractAnimation::Direction direction =
            checked ? QAbstractAnimation::Forward : QAbstractAnimation::Backward;
    bool pause = animation->state() == QAbstractAnimation::Running;
    if(pause)
        animation->pause();
    animation->setDirection(direction);
    if(pause)
        animation->resume();
    else
        animation->start(QAbstractAnimation::KeepWhenStopped);
    update();
    
  4. 更復雜的樣式

    整個繪製全部使用 QStyle 介面,除了動畫時間使用了固定值,其他所有樣式完全通過 QSS 設計,按鈕 pressed、hover 等狀態下的樣式也不會受到影響。

    如果要針對 handle 單獨設定 hover、pressed 等樣式,需要根據滑鼠位置計算是否在 handle 上,並設定 QStyleOptionComplex::activeSubControls 和 QStyleOption::state 後繪製,判斷座標點位置的子控制元件也有對應的介面:

    virtual QStyle::SubControl  QStyle::hitTestComplexControl(...)
    

    如果handle不需要拖拽動作,支援的意義不大。不過,Win10 設定裡的 Switch Button 是支援滑鼠拖拽的,當拖拽超過半個按鈕寬度會切換狀態,釋放後 handle 會從釋放位置滑向一邊。要支援的話需要兼顧 QPushButon 的原生的滑鼠動作,比較麻煩。

其他不同的 Switch Button

  • 文章開頭的動態圖展示了上述程式碼最終的結果。網路上也有其他稍有差別的 Switch Button,最後做個總結。
  1. 槽與滑塊高度不同
    槽與滑塊高度不同
    這種可以通過修改上述樣式實現:

    /* 通過修改 marin實現 */
    SwitchButton{
        border: none;
        border-radius: 10px;
        height: 20px;
        margin: 5px;
    }
    SwitchButton::handle{
        background: white;
        border:none;
        min-width:30px;
        max-width:30px;
        margin:-5px;
        border-radius:15px;
    }
    
  2. 有表示開、關狀態的文字
    有開關文字
    這種可以增加繪製按鈕文字的邏輯,不過需要控制文字位置,可以根據滑塊位置和按鈕左側,居中繪製文字。

  3. 滑塊左側和右側顏色不同
    左右顏色不同
    這種可以通過修改繪製繪製區域來實現:

    sliderOpt.rect = style()->subControlRect(QStyle::CC_ScrollBar, &sliderOpt, QStyle::SC_ScrollBarSlider, this);
    
    // 將繪製槽的第二層背景色程式碼移動到獲取handle位置之後,修改繪製區域右側到handle右側
    buttonOpt.state |= QStyle::State_On;
    buttonOpt.rect.setRight(sliderOpt.rect.right());
    style()->drawControl(QStyle::CE_PushButtonBevel, &buttonOpt, &painter, this);
    
    style()->drawControl(QStyle::CE_ScrollBarSlider, &sliderOpt, &painter, this);
    

    改變繪製區域可能會因為一些margin、padding等不一致引起偏移。

總結

  • 如果使用 QPainter 自己繪製,開放介面設定顏色、圓角等樣式可能更方便快速開發,上述方法確實過於複雜。
  • 嘗試實現的過程試過不同的 QStyleOption ,不合適就要重來,檢視Qt原始碼來確定狀態和介面是可用的,花費的時間太長。多種控制元件的混合繪製導致預設樣式非常醜,也僅能用於使用 QSS 定製樣式的專案。
  • 就這些,希望各位能有所收穫。

相關文章