ui/widget/material/widget/button/button.cpp¶
Material Design 3 Button Implementation. More...
Namespaces¶
| Name |
|---|
| cf |
| cf::ui |
| cf::ui::widget |
| cf::ui::widget::material |
Detailed Description¶
Material Design 3 Button Implementation.
Author: Material Design Framework Team
Version: 0.1
Since: 0.1
Date: 2026-03-01
Implements a Material Design 3 button with 5 variants: Filled, Tonal, Outlined, Text, and Elevated. Supports ripple effects, state layers, elevation shadows, and focus indicators.
Source code¶
#include "button.h"
#include "application_support/application.h"
#include "base/device_pixel.h"
#include "base/geometry_helper.h"
#include "cfmaterial_animation_factory.h"
#include "core/token/material_scheme/cfmaterial_token_literals.h"
#include "widget/material/base/elevation_controller.h"
#include "widget/material/base/focus_ring.h"
#include "widget/material/base/ripple_helper.h"
#include "widget/material/base/state_machine.h"
#include <QApplication>
#include <QFontMetrics>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
namespace cf::ui::widget::material {
using namespace cf::ui::base;
using namespace cf::ui::base::device;
using namespace cf::ui::base::geometry;
using namespace cf::ui::components;
using namespace cf::ui::components::material;
using namespace cf::ui::core;
using namespace cf::ui::core::token::literals;
using namespace cf::ui::widget::material::base;
using namespace cf::ui::widget::application_support;
// ============================================================================
// Constructor / Destructor
// ============================================================================
Button::Button(ButtonVariant variant, QWidget* parent) : QPushButton(parent), variant_(variant) {
// Get animation factory from Application
m_animationFactory =
cf::WeakPtr<CFMaterialAnimationFactory>::DynamicCast(Application::animationFactory());
// Initialize behavior components
m_stateMachine = new StateMachine(m_animationFactory, this);
m_ripple = new RippleHelper(m_animationFactory, this);
m_elevation = new MdElevationController(m_animationFactory, this);
m_focusIndicator = new MdFocusIndicator(m_animationFactory, this);
// Set initial elevation based on variant
// All buttons now have elevation 2 by default for press effect
m_elevation->setElevation(2);
// Set ripple mode
m_ripple->setMode(RippleHelper::Mode::Bounded);
// Connect repaint signals
connect(m_ripple, &RippleHelper::repaintNeeded, this, QOverload<>::of(&Button::update));
connect(m_stateMachine, &StateMachine::stateLayerOpacityChanged, this,
QOverload<>::of(&Button::update));
connect(m_elevation, &MdElevationController::pressOffsetChanged, this,
QOverload<>::of(&Button::update));
// Set default font
setFont(labelFont());
}
Button::Button(const QString& text, ButtonVariant variant, QWidget* parent)
: Button(variant, parent) {
setText(text);
}
Button::~Button() {
// Components are parented to this, Qt will delete them automatically
}
// ============================================================================
// Event Handlers (moved from inline in header)
// ============================================================================
void Button::enterEvent(QEnterEvent* event) {
QPushButton::enterEvent(event);
if (m_stateMachine)
m_stateMachine->onHoverEnter();
update();
}
void Button::leaveEvent(QEvent* event) {
QPushButton::leaveEvent(event);
if (m_stateMachine)
m_stateMachine->onHoverLeave();
if (m_ripple)
m_ripple->onCancel();
update();
}
void Button::mousePressEvent(QMouseEvent* event) {
QPushButton::mousePressEvent(event);
if (m_stateMachine)
m_stateMachine->onPress(event->pos());
if (m_ripple)
m_ripple->onPress(event->pos(), rect());
if (m_elevation && m_pressEffectEnabled)
m_elevation->setPressed(true);
update();
}
void Button::mouseReleaseEvent(QMouseEvent* event) {
QPushButton::mouseReleaseEvent(event);
if (m_stateMachine)
m_stateMachine->onRelease();
if (m_ripple)
m_ripple->onRelease();
if (m_elevation && m_pressEffectEnabled)
m_elevation->setPressed(false);
update();
}
void Button::focusInEvent(QFocusEvent* event) {
QPushButton::focusInEvent(event);
if (m_stateMachine)
m_stateMachine->onFocusIn();
if (m_focusIndicator)
m_focusIndicator->onFocusIn();
update();
}
void Button::focusOutEvent(QFocusEvent* event) {
QPushButton::focusOutEvent(event);
if (m_stateMachine)
m_stateMachine->onFocusOut();
if (m_focusIndicator)
m_focusIndicator->onFocusOut();
update();
}
void Button::changeEvent(QEvent* event) {
QPushButton::changeEvent(event);
if (event->type() == QEvent::EnabledChange) {
if (m_stateMachine) {
if (isEnabled()) {
m_stateMachine->onEnable();
} else {
m_stateMachine->onDisable();
}
}
update();
}
}
// ============================================================================
// Property Getters/Setters
// ============================================================================
int Button::elevation() const {
return m_elevation ? m_elevation->elevation() : 0;
}
void Button::setElevation(int level) {
if (m_elevation) {
m_elevation->setElevation(level);
update();
}
}
void Button::setLightSourceAngle(float degrees) {
if (m_elevation) {
m_elevation->setLightSourceAngle(degrees);
update();
}
}
float Button::lightSourceAngle() const {
return m_elevation ? m_elevation->lightSourceAngle() : 15.0f;
}
void Button::setLeadingIcon(const QIcon& icon) {
leadingIcon_ = icon;
updateGeometry();
update();
}
Button::ButtonVariant Button::variant() const {
return variant_;
}
void Button::setVariant(ButtonVariant variant) {
if (variant_ != variant) {
variant_ = variant;
// 变体改变时保持 elevation 不变(统一使用 level 2)
updateGeometry();
update();
}
}
bool Button::pressEffectEnabled() const {
return m_pressEffectEnabled;
}
void Button::setPressEffectEnabled(bool enabled) {
if (m_pressEffectEnabled != enabled) {
m_pressEffectEnabled = enabled;
update();
}
}
// ============================================================================
// Size Hints
// ============================================================================
QSize Button::sizeHint() const {
// Material Design button specifications:
// - Height: 40dp (content area)
// - Horizontal padding: 24dp
// - Icon size: 18dp, 8dp gap from text
CanvasUnitHelper helper(qApp->devicePixelRatio());
// 内容区域高度
float contentHeight = helper.dpToPx(40.0f);
float hPadding = helper.dpToPx(24.0f);
float iconWidth = 0;
float iconGap = 0;
if (!leadingIcon_.isNull()) {
iconWidth = helper.dpToPx(18.0f);
iconGap = helper.dpToPx(8.0f);
}
float textWidth = fontMetrics().horizontalAdvance(text());
float contentWidth = hPadding * 2 + iconWidth + iconGap + textWidth;
// 加上阴影边距
QMarginsF margin = shadowMargin();
float totalWidth = contentWidth + margin.left() + margin.right();
float totalHeight = contentHeight + margin.top() + margin.bottom();
return QSize(int(std::ceil(totalWidth)), int(std::ceil(totalHeight)));
}
QSize Button::minimumSizeHint() const {
// Minimum width respects padding and some text
CanvasUnitHelper helper(qApp->devicePixelRatio());
// 内容区域高度
float contentHeight = helper.dpToPx(40.0f);
float hPadding = helper.dpToPx(24.0f);
float iconWidth = leadingIcon_.isNull() ? 0 : helper.dpToPx(18.0f);
float iconGap = leadingIcon_.isNull() ? 0 : helper.dpToPx(8.0f);
// Minimum text width (approximately 3 characters)
float minTextWidth = fontMetrics().horizontalAdvance("MMM");
float contentWidth = hPadding * 2 + iconWidth + iconGap + minTextWidth;
// 加上阴影边距
QMarginsF margin = shadowMargin();
float totalWidth = contentWidth + margin.left() + margin.right();
float totalHeight = contentHeight + margin.top() + margin.bottom();
return QSize(int(std::ceil(totalWidth)), int(std::ceil(totalHeight)));
}
// ============================================================================
// Color Access Methods
// ============================================================================
namespace {
// Fallback colors when theme is not available
inline CFColor fallbackPrimary() {
return CFColor(103, 80, 164);
} // Purple 700
inline CFColor fallbackOnPrimary() {
return CFColor(255, 255, 255);
} // White
inline CFColor fallbackPrimaryContainer() {
return CFColor(234, 221, 255);
}
inline CFColor fallbackOnPrimaryContainer() {
return CFColor(29, 25, 67);
}
inline CFColor fallbackSecondary() {
return CFColor(101, 163, 207);
} // Light Blue
inline CFColor fallbackSecondaryContainer() {
return CFColor(179, 218, 255);
}
inline CFColor fallbackOnSecondaryContainer() {
return CFColor(0, 46, 73);
}
inline CFColor fallbackOutline() {
return CFColor(120, 124, 132);
} // Outline
inline CFColor fallbackSurface() {
return CFColor(253, 253, 253);
} // Surface
inline CFColor fallbackOnSurface() {
return CFColor(27, 27, 31);
} // On Surface
} // namespace
CFColor Button::containerColor() const {
auto* app = Application::instance();
if (!app) {
return fallbackSurface();
}
try {
const auto& theme = app->currentTheme();
auto& colorScheme = theme.color_scheme();
switch (variant_) {
case ButtonVariant::Filled:
return CFColor(colorScheme.queryColor(PRIMARY));
case ButtonVariant::Tonal:
return CFColor(colorScheme.queryColor(SECONDARY_CONTAINER));
case ButtonVariant::Outlined:
case ButtonVariant::Text:
return CFColor(colorScheme.queryColor(SURFACE));
case ButtonVariant::Elevated:
return CFColor(colorScheme.queryColor(SURFACE));
}
} catch (...) {
// Fallback if theme access fails
}
switch (variant_) {
case ButtonVariant::Filled:
return fallbackPrimary();
case ButtonVariant::Tonal:
return fallbackSecondaryContainer();
case ButtonVariant::Outlined:
case ButtonVariant::Text:
case ButtonVariant::Elevated:
return fallbackSurface();
}
return fallbackSurface();
}
CFColor Button::labelColor() const {
auto* app = Application::instance();
if (!app) {
return fallbackOnSurface();
}
try {
const auto& theme = app->currentTheme();
auto& colorScheme = theme.color_scheme();
switch (variant_) {
case ButtonVariant::Filled:
return CFColor(colorScheme.queryColor(ON_PRIMARY));
case ButtonVariant::Tonal:
return CFColor(colorScheme.queryColor(ON_SECONDARY_CONTAINER));
case ButtonVariant::Outlined:
case ButtonVariant::Text:
case ButtonVariant::Elevated:
return CFColor(colorScheme.queryColor(PRIMARY));
}
} catch (...) {
// Fallback if theme access fails
}
switch (variant_) {
case ButtonVariant::Filled:
return fallbackOnPrimary();
case ButtonVariant::Tonal:
return fallbackOnPrimaryContainer();
case ButtonVariant::Outlined:
case ButtonVariant::Text:
case ButtonVariant::Elevated:
return fallbackPrimary();
}
return fallbackOnSurface();
}
CFColor Button::stateLayerColor() const {
auto* app = Application::instance();
if (!app) {
return fallbackOnSurface();
}
try {
const auto& theme = app->currentTheme();
auto& colorScheme = theme.color_scheme();
// State layer color is the same as label color for all variants
return labelColor();
} catch (...) {
return labelColor();
}
}
CFColor Button::outlineColor() const {
auto* app = Application::instance();
if (!app) {
return fallbackOutline();
}
try {
const auto& theme = app->currentTheme();
auto& colorScheme = theme.color_scheme();
return CFColor(colorScheme.queryColor(OUTLINE));
} catch (...) {
return fallbackOutline();
}
}
float Button::cornerRadius() const {
// Full rounded corners (50% of height)
return height() / 2.0f;
}
QFont Button::labelFont() const {
auto* app = Application::instance();
if (!app) {
// Fallback to system font with reasonable size
QFont font = QPushButton::font();
font.setPixelSize(14);
font.setWeight(QFont::Medium);
return font;
}
try {
const auto& theme = app->currentTheme();
auto& fontType = theme.font_type();
return fontType.queryTargetFont("labelLarge");
} catch (...) {
QFont font = QPushButton::font();
font.setPixelSize(14);
font.setWeight(QFont::Medium);
return font;
}
}
// ============================================================================
// Paint Event
// ============================================================================
void Button::paintEvent(QPaintEvent* event) {
Q_UNUSED(event)
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
// 计算按压偏移
float pressOffset = 0.0f;
if (m_pressEffectEnabled && m_elevation) {
pressOffset = m_elevation->pressOffset();
}
// Calculate content area (inset to make room for shadow)
QMarginsF margin = shadowMargin();
QRectF contentRect =
QRectF(rect()).adjusted(margin.left(), margin.top(), -margin.right(), -margin.bottom());
// 应用按压偏移 - 整体向下平移,而不是压缩顶部
contentRect.translate(0, pressOffset);
// Create shape path (full rounded corners) using content area
QPainterPath shape = roundedRect(contentRect, cornerRadius());
// Step 1: Draw shadow (Elevated variant only) - uses contentRect
drawShadow(p, contentRect, shape);
// Step 2: Draw background
drawBackground(p, shape);
// Step 3: Draw state layer
drawStateLayer(p, shape);
// Step 4: Draw ripple
drawRipple(p, shape);
// Step 5: Draw outline (Outlined variant only)
if (variant_ == ButtonVariant::Outlined) {
drawOutline(p, shape);
}
// Step 6: Draw content (icon + text)
drawContent(p, contentRect);
// Step 7: Draw focus indicator
drawFocusIndicator(p, shape);
}
// ============================================================================
// Drawing Helpers
// ============================================================================
// Calculate shadow margin (extra space needed for shadow)
QMarginsF Button::shadowMargin() const {
if (!m_elevation || m_elevation->elevation() <= 0) {
return QMarginsF(0, 0, 0, 0);
}
CanvasUnitHelper helper(qApp->devicePixelRatio());
// 根据 elevation 级别动态计算边距
// Level 2: blur=4dp, offset=2dp, 最大偏移约 3dp
// 预留边距 = offset + blur/2,更精确的阴影空间
int level = m_elevation->elevation();
float margin = helper.dpToPx(2.0f + level * 1.5f); // level 2: 约 5dp
return QMarginsF(margin, margin, margin, margin);
}
void Button::drawShadow(QPainter& p, const QRectF& contentRect, const QPainterPath& shape) {
if (m_elevation && m_elevation->elevation() > 0) {
m_elevation->paintShadow(&p, shape);
}
}
void Button::drawBackground(QPainter& p, const QPainterPath& shape) {
CFColor bg = containerColor();
// Handle disabled state
if (!isEnabled()) {
QColor color = bg.native_color();
color.setAlphaF(0.38f); // 38% opacity for disabled
p.fillPath(shape, color);
return;
}
// Text variant has transparent background
if (variant_ == ButtonVariant::Text) {
return;
}
p.fillPath(shape, bg.native_color());
}
void Button::drawStateLayer(QPainter& p, const QPainterPath& shape) {
if (!isEnabled() || !m_stateMachine) {
return;
}
float opacity = m_stateMachine->stateLayerOpacity();
if (opacity <= 0.0f) {
return;
}
CFColor stateColor = stateLayerColor();
QColor color = stateColor.native_color();
color.setAlphaF(color.alphaF() * opacity);
p.fillPath(shape, color);
}
void Button::drawRipple(QPainter& p, const QPainterPath& shape) {
if (m_ripple) {
// Set ripple color based on label color (content color)
m_ripple->setColor(labelColor());
m_ripple->paint(&p, shape);
}
}
void Button::drawOutline(QPainter& p, const QPainterPath& shape) {
// 使用 1px inset(而不是 dp)让 outline 刚好在边缘内侧
// QPen 描边是居中对齐的,所以 0.5px 在内,0.5px 在外
float inset = 0.5f;
QColor color = outlineColor().native_color();
if (!isEnabled()) {
color.setAlphaF(0.38f);
}
p.save();
// Create inset path for outline
QRectF shapeBounds = shape.boundingRect();
QRectF insetRect = shapeBounds.adjusted(inset, inset, -inset, -inset);
float adjustedRadius = std::max(0.0f, cornerRadius() - inset);
QPainterPath insetShape = roundedRect(insetRect, adjustedRadius);
QPen pen(color, 1.0); // 1px width
pen.setCosmetic(true); // Keep consistent 1px width across DPI
p.setPen(pen);
p.setBrush(Qt::NoBrush);
p.drawPath(insetShape);
p.restore();
}
void Button::drawContent(QPainter& p, const QRectF& contentRect) {
CFColor textColor = labelColor();
if (!isEnabled()) {
QColor color = textColor.native_color();
color.setAlphaF(0.38f);
p.setPen(color);
} else {
p.setPen(textColor.native_color());
}
// Use button's font
QFont font = labelFont();
p.setFont(font);
// Use contentRect passed in (already inset for shadow margin)
QRectF textArea = contentRect;
CanvasUnitHelper helper(qApp->devicePixelRatio());
float hPadding = helper.dpToPx(24.0f);
textArea.adjust(hPadding, 0, -hPadding, 0);
// Calculate icon and text positions
float iconWidth = 0;
float iconGap = 0;
if (!leadingIcon_.isNull()) {
iconWidth = helper.dpToPx(18.0f);
iconGap = helper.dpToPx(8.0f);
}
float textWidth = fontMetrics().horizontalAdvance(text());
float totalContentWidth = iconWidth + iconGap + textWidth;
float startX = textArea.left() + (textArea.width() - totalContentWidth) / 2.0f;
float centerY = textArea.center().y();
// Draw icon
if (!leadingIcon_.isNull()) {
float iconSize = helper.dpToPx(18.0f);
QRectF iconRect(startX, centerY - iconSize / 2, iconSize, iconSize);
leadingIcon_.paint(&p, iconRect.toRect());
startX += iconWidth + iconGap;
}
// Draw text
QRectF textRect(startX, textArea.top(), textWidth, textArea.height());
p.drawText(textRect, Qt::AlignCenter, text());
}
void Button::drawFocusIndicator(QPainter& p, const QPainterPath& shape) {
if (m_focusIndicator) {
m_focusIndicator->paint(&p, shape, labelColor());
}
}
} // namespace cf::ui::widget::material
Updated on 2026-03-09 at 10:14:01 +0000