1// Copyright (C) 2023 The Qt Company Ltd.
2// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
3
4#include "qquickmaterialtextcontainer_p.h"
5
6#include <QtCore/qpropertyanimation.h>
7#include <QtGui/qpainter.h>
8#include <QtGui/qpainterpath.h>
9#include <QtQml/qqmlinfo.h>
10
11QT_BEGIN_NAMESPACE
12
13/*
14 This class exists because:
15
16 - Rectangle doesn't support individual radii for each corner (QTBUG-48774).
17 - We need to draw an interrupted (where the placeholder text is) line for outlined containers.
18 - We need to animate the focus line for filled containers, and we can't use "Behavior on"
19 syntax because we only want to animate activeFocus becoming true, not also false. To do this
20 requires imperative code, and we want to keep the QML declarative.
21
22 focusAnimationProgress has to be a property even though it's only used internally,
23 because we have to use QPropertyAnimation on it.
24
25 An advantage of doing the animation in C++ is that we avoid the memory
26 overhead of an animation instance even when we're not using it, and instead
27 create it on demand and delete it when it's done. I tried doing the animation
28 declaratively with states and transitions, but it was more difficult to implement
29 and would have been harder to maintain, as well as having more overhead.
30*/
31
32QQuickMaterialTextContainer::QQuickMaterialTextContainer(QQuickItem *parent)
33 : QQuickPaintedItem(parent)
34{
35}
36
37bool QQuickMaterialTextContainer::isFilled() const
38{
39 return m_filled;
40}
41
42void QQuickMaterialTextContainer::setFilled(bool filled)
43{
44 if (filled == m_filled)
45 return;
46
47 m_filled = filled;
48 update();
49}
50
51QColor QQuickMaterialTextContainer::fillColor() const
52{
53 return m_fillColor;
54}
55
56void QQuickMaterialTextContainer::setFillColor(const QColor &fillColor)
57{
58 if (fillColor == m_fillColor)
59 return;
60
61 m_fillColor = fillColor;
62 update();
63}
64
65QColor QQuickMaterialTextContainer::outlineColor() const
66{
67 return m_outlineColor;
68}
69
70void QQuickMaterialTextContainer::setOutlineColor(const QColor &outlineColor)
71{
72 if (outlineColor == m_outlineColor)
73 return;
74
75 m_outlineColor = outlineColor;
76 update();
77}
78
79QColor QQuickMaterialTextContainer::focusedOutlineColor() const
80{
81 return m_outlineColor;
82}
83
84void QQuickMaterialTextContainer::setFocusedOutlineColor(const QColor &focusedOutlineColor)
85{
86 if (focusedOutlineColor == m_focusedOutlineColor)
87 return;
88
89 m_focusedOutlineColor = focusedOutlineColor;
90 update();
91}
92
93qreal QQuickMaterialTextContainer::focusAnimationProgress() const
94{
95 return m_focusAnimationProgress;
96}
97
98void QQuickMaterialTextContainer::setFocusAnimationProgress(qreal progress)
99{
100 if (qFuzzyCompare(p1: progress, p2: m_focusAnimationProgress))
101 return;
102
103 m_focusAnimationProgress = progress;
104 update();
105}
106
107qreal QQuickMaterialTextContainer::placeholderTextWidth() const
108{
109 return m_placeholderTextWidth;
110}
111
112void QQuickMaterialTextContainer::setPlaceholderTextWidth(qreal placeholderTextWidth)
113{
114 if (qFuzzyCompare(p1: placeholderTextWidth, p2: m_placeholderTextWidth))
115 return;
116
117 m_placeholderTextWidth = placeholderTextWidth;
118 update();
119}
120
121bool QQuickMaterialTextContainer::controlHasActiveFocus() const
122{
123 return m_controlHasActiveFocus;
124}
125
126void QQuickMaterialTextContainer::setControlHasActiveFocus(bool controlHasActiveFocus)
127{
128 if (m_controlHasActiveFocus == controlHasActiveFocus)
129 return;
130
131 m_controlHasActiveFocus = controlHasActiveFocus;
132 if (m_controlHasActiveFocus)
133 controlGotActiveFocus();
134 else
135 controlLostActiveFocus();
136 emit controlHasActiveFocusChanged();
137}
138
139bool QQuickMaterialTextContainer::controlHasText() const
140{
141 return m_controlHasText;
142}
143
144void QQuickMaterialTextContainer::setControlHasText(bool controlHasText)
145{
146 if (m_controlHasText == controlHasText)
147 return;
148
149 m_controlHasText = controlHasText;
150 // TextArea's text length is updated after component completion,
151 // so account for that here and in setPlaceholderHasText().
152 maybeSetFocusAnimationProgress();
153 update();
154 emit controlHasTextChanged();
155}
156
157bool QQuickMaterialTextContainer::placeholderHasText() const
158{
159 return m_placeholderHasText;
160}
161
162void QQuickMaterialTextContainer::setPlaceholderHasText(bool placeholderHasText)
163{
164 if (m_placeholderHasText == placeholderHasText)
165 return;
166
167 m_placeholderHasText = placeholderHasText;
168 maybeSetFocusAnimationProgress();
169 update();
170 emit placeholderHasTextChanged();
171}
172
173int QQuickMaterialTextContainer::horizontalPadding() const
174{
175 return m_horizontalPadding;
176}
177
178/*!
179 \internal
180
181 The text field's horizontal padding.
182
183 We need this to be a property so that the QML can set it, since we can't
184 access QQuickMaterialStyle's C++ API from this plugin.
185*/
186void QQuickMaterialTextContainer::setHorizontalPadding(int horizontalPadding)
187{
188 if (m_horizontalPadding == horizontalPadding)
189 return;
190 m_horizontalPadding = horizontalPadding;
191 update();
192 emit horizontalPaddingChanged();
193}
194
195void QQuickMaterialTextContainer::paint(QPainter *painter)
196{
197 qreal w = width();
198 qreal h = height();
199 if (w <= 0 || h <= 0)
200 return;
201
202 // Account for pen width.
203 const qreal penWidth = m_filled ? 1 : (m_controlHasActiveFocus ? 2 : 1);
204 w -= penWidth;
205 h -= penWidth;
206
207 const qreal cornerRadius = 4;
208 // This is coincidentally the same as cornerRadius, but use different variable names
209 // to keep the code understandable.
210 const qreal gapPadding = 4;
211 QPainterPath path;
212
213 QPointF startPos;
214
215 // Top-left rounded corner.
216 if (m_filled || m_focusAnimationProgress == 0) {
217 startPos = QPointF(cornerRadius, 0);
218 } else {
219 // When animating focus on outlined containers, we need to make a gap
220 // at the top left for the placeholder text.
221 // If the text is too wide for the container, it will be elided, so
222 // we shouldn't need to clamp its width here. TODO: check that this is the case for TextArea.
223 const qreal halfPlaceholderWidth = m_placeholderTextWidth / 2;
224 // Left padding plus half of the placeholder text gives the center of the placeholder text gap.
225 // Note that the placeholder gap is always aligned to the left side of the TextField,
226 // not the center, so we can't just use half the container's width.
227 const qreal gapCenterX = m_horizontalPadding + halfPlaceholderWidth;
228 // Start at the center of the gap and animate outwards towards the left-hand side.
229 // Subtract gapPadding to account for the gap between the line and the placeholder text.
230 // Also subtract the pen width because otherwise it extends by that distance too much to the right.
231 // Changing the cap style to Qt::FlatCap would only fix this by half the pen width,
232 // but it has no effect anyway (perhaps it literally only affects end points and not "start" points?).
233 startPos = QPointF(gapCenterX - (m_focusAnimationProgress * halfPlaceholderWidth) - gapPadding - penWidth, 0);
234 }
235 path.moveTo(p: startPos);
236 path.arcTo(x: 0, y: 0, w: cornerRadius * 2, h: cornerRadius * 2, startAngle: 90, arcLength: 90);
237
238 // Bottom-left corner.
239 if (m_filled) {
240 path.lineTo(x: 0, y: h);
241 } else {
242 path.lineTo(x: 0, y: h - cornerRadius * 2);
243 path.arcTo(x: 0, y: h - cornerRadius * 2, w: cornerRadius * 2, h: cornerRadius * 2, startAngle: 180, arcLength: 90);
244 }
245
246 // Bottom-right corner.
247 if (m_filled) {
248 path.lineTo(x: w, y: h);
249 } else {
250 path.lineTo(x: w - cornerRadius * 2, y: h);
251 path.arcTo(x: w - cornerRadius * 2, y: h - cornerRadius * 2, w: cornerRadius * 2, h: cornerRadius * 2, startAngle: 270, arcLength: 90);
252 }
253
254 // Top-right rounded corner.
255 path.lineTo(x: w, y: cornerRadius);
256 path.arcTo(x: w - (cornerRadius * 2), y: 0, w: cornerRadius * 2, h: cornerRadius * 2, startAngle: 0, arcLength: 90);
257
258 if (m_filled || qFuzzyIsNull(d: m_focusAnimationProgress)) {
259 // Back to the start.
260 path.lineTo(x: startPos.x(), y: startPos.y());
261 } else {
262 // Go to the end (right-hand side) of the gap.
263 const qreal halfPlaceholderWidth = (/*placeholderTextGap * 2 + */m_placeholderTextWidth) / 2;
264 const qreal gapCenterX = m_horizontalPadding + halfPlaceholderWidth;
265 // Just "+ placeholderTextGap" should be enough to get us to the correct place - not
266 // sure why doubling it is necessary...
267 path.lineTo(x: gapCenterX + (m_focusAnimationProgress * halfPlaceholderWidth) + gapPadding, y: startPos.y());
268 }
269
270 // Account for pen width.
271 painter->translate(dx: penWidth / 2, dy: penWidth / 2);
272
273 painter->setRenderHint(hint: QPainter::Antialiasing, on: true);
274
275 auto control = textControl();
276 const bool focused = control && control->hasActiveFocus();
277 // We still want to draw the stroke when it's filled, otherwise it will be a pixel
278 // (the pen width) too narrow on either side.
279 QPen pen;
280 pen.setColor(m_filled ? m_fillColor : (focused ? m_focusedOutlineColor : m_outlineColor));
281 pen.setWidthF(penWidth);
282 painter->setPen(pen);
283 if (m_filled)
284 painter->setBrush(QBrush(m_fillColor));
285
286 // Fill or stroke the container's shape.
287 // If not filling, the default brush will be used, which is Qt::NoBrush.
288 painter->drawPath(path);
289
290 // Draw the focus line at the bottom for filled containers.
291 if (m_filled) {
292 if (!qFuzzyCompare(p1: m_focusAnimationProgress, p2: 1.0)) {
293 // Draw the enabled active indicator line (#10) that's at the bottom when it's not focused:
294 // https://m3.material.io/components/text-fields/specs#6d654d1d-262e-4697-858c-9a75e8e7c81d
295 // Don't bother drawing it when the animation has finished, as the focused active indicator
296 // line below will obscure it.
297 pen.setColor(m_outlineColor);
298 painter->setPen(pen);
299 painter->drawLine(x1: 0, y1: h, x2: w, y2: h);
300 }
301
302 if (!qFuzzyIsNull(d: m_focusAnimationProgress)) {
303 // Draw the focused active indicator line (#6) that's at the bottom when it's focused.
304 // Start at the center and expand outwards.
305 const int lineLength = m_focusAnimationProgress * w;
306 const int horizontalCenter = w / 2;
307 pen.setColor(m_focusedOutlineColor);
308 pen.setWidth(2);
309 painter->setPen(pen);
310 painter->drawLine(x1: horizontalCenter - (lineLength / 2), y1: h,
311 x2: horizontalCenter + (lineLength / 2) + pen.width() / 2, y2: h);
312 }
313 }
314}
315
316bool QQuickMaterialTextContainer::shouldAnimateOutline() const
317{
318 return !m_controlHasText && m_placeholderHasText;
319}
320
321/*!
322 \internal
323
324 \sa QQuickPlaceholderText::textControl().
325*/
326QQuickItem *QQuickMaterialTextContainer::textControl() const
327{
328 return qobject_cast<QQuickItem *>(o: parent());
329}
330
331void QQuickMaterialTextContainer::controlGotActiveFocus()
332{
333 const bool shouldAnimate = m_filled ? !m_controlHasText : shouldAnimateOutline();
334 if (!shouldAnimate) {
335 // It does have focus, but sometimes we don't need to animate anything, just change colors.
336 if (m_filled && m_controlHasText) {
337 // When a filled container has text already entered, we should just immediately change
338 // the color and thickness of the indicator line.
339 m_focusAnimationProgress = 1;
340 }
341 update();
342 return;
343 }
344
345 startFocusAnimation();
346}
347
348void QQuickMaterialTextContainer::controlLostActiveFocus()
349{
350 // We don't want to animate the active indicator line (at the bottom) of filled containers
351 // when the control loses focus, only when it gets it.
352 if (m_filled || !shouldAnimateOutline()) {
353 // Ensure that we set this so that filled containers go back to a non-accent-colored
354 // active indicator line when losing focus.
355 if (m_filled)
356 m_focusAnimationProgress = 0;
357 update();
358 return;
359 }
360
361 QPropertyAnimation *animation = new QPropertyAnimation(this, "focusAnimationProgress", this);
362 animation->setDuration(300);
363 animation->setStartValue(1);
364 animation->setEndValue(0);
365 animation->start(policy: QAbstractAnimation::DeleteWhenStopped);
366}
367
368void QQuickMaterialTextContainer::startFocusAnimation()
369{
370 // Each time setFocusAnimationProgress is called by the animation, it'll call update(),
371 // which will cause us to be re-rendered.
372 QPropertyAnimation *animation = new QPropertyAnimation(this, "focusAnimationProgress", this);
373 animation->setDuration(300);
374 animation->setStartValue(0);
375 animation->setEndValue(1);
376 animation->start(policy: QAbstractAnimation::DeleteWhenStopped);
377}
378
379void QQuickMaterialTextContainer::maybeSetFocusAnimationProgress()
380{
381 if (m_filled)
382 return;
383
384 if (m_controlHasText && m_placeholderHasText) {
385 // Show the interrupted outline when there is text.
386 setFocusAnimationProgress(1);
387 } else if (!m_controlHasText && !m_controlHasActiveFocus) {
388 // If the text was cleared while it didn't have focus, don't animate, just close the gap.
389 setFocusAnimationProgress(0);
390 }
391}
392
393void QQuickMaterialTextContainer::componentComplete()
394{
395 QQuickPaintedItem::componentComplete();
396
397 if (!parentItem())
398 qmlWarning(me: this) << "Expected parent item by component completion!";
399
400 maybeSetFocusAnimationProgress();
401}
402
403QT_END_NAMESPACE
404

source code of qtdeclarative/src/quickcontrols/material/impl/qquickmaterialtextcontainer.cpp