1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.support.design.widget;
18 
19 import android.content.Context;
20 import android.content.res.ColorStateList;
21 import android.content.res.TypedArray;
22 import android.graphics.Canvas;
23 import android.graphics.Color;
24 import android.graphics.Paint;
25 import android.graphics.Typeface;
26 import android.support.annotation.Nullable;
27 import android.support.annotation.StyleRes;
28 import android.support.design.R;
29 import android.support.v4.view.AccessibilityDelegateCompat;
30 import android.support.v4.view.GravityCompat;
31 import android.support.v4.view.ViewCompat;
32 import android.support.v4.view.ViewPropertyAnimatorListenerAdapter;
33 import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
34 import android.support.v7.internal.widget.TintManager;
35 import android.text.Editable;
36 import android.text.TextUtils;
37 import android.text.TextWatcher;
38 import android.util.AttributeSet;
39 import android.util.TypedValue;
40 import android.view.Gravity;
41 import android.view.View;
42 import android.view.ViewGroup;
43 import android.view.accessibility.AccessibilityEvent;
44 import android.view.animation.AccelerateInterpolator;
45 import android.widget.EditText;
46 import android.widget.LinearLayout;
47 import android.widget.TextView;
48 
49 /**
50  * Layout which wraps an {@link android.widget.EditText} (or descendant) to show a floating label
51  * when the hint is hidden due to the user inputting text.
52  *
53  * Also supports showing an error via {@link #setErrorEnabled(boolean)} and
54  * {@link #setError(CharSequence)}.
55  */
56 public class TextInputLayout extends LinearLayout {
57 
58     private static final int ANIMATION_DURATION = 200;
59 
60     private EditText mEditText;
61     private CharSequence mHint;
62 
63     private Paint mTmpPaint;
64 
65     private boolean mErrorEnabled;
66     private TextView mErrorView;
67     private int mErrorTextAppearance;
68 
69     private ColorStateList mDefaultTextColor;
70     private ColorStateList mFocusedTextColor;
71 
72     private final CollapsingTextHelper mCollapsingTextHelper = new CollapsingTextHelper(this);
73 
74     private boolean mHintAnimationEnabled;
75     private ValueAnimatorCompat mAnimator;
76 
TextInputLayout(Context context)77     public TextInputLayout(Context context) {
78         this(context, null);
79     }
80 
TextInputLayout(Context context, AttributeSet attrs)81     public TextInputLayout(Context context, AttributeSet attrs) {
82         this(context, attrs, 0);
83     }
84 
TextInputLayout(Context context, AttributeSet attrs, int defStyleAttr)85     public TextInputLayout(Context context, AttributeSet attrs, int defStyleAttr) {
86         // Can't call through to super(Context, AttributeSet, int) since it doesn't exist on API 10
87         super(context, attrs);
88 
89         setOrientation(VERTICAL);
90         setWillNotDraw(false);
91         setAddStatesFromChildren(true);
92 
93         mCollapsingTextHelper.setTextSizeInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
94         mCollapsingTextHelper.setPositionInterpolator(new AccelerateInterpolator());
95         mCollapsingTextHelper.setCollapsedTextGravity(Gravity.TOP | GravityCompat.START);
96 
97         final TypedArray a = context.obtainStyledAttributes(attrs,
98                 R.styleable.TextInputLayout, defStyleAttr, R.style.Widget_Design_TextInputLayout);
99         mHint = a.getText(R.styleable.TextInputLayout_android_hint);
100         mHintAnimationEnabled = a.getBoolean(
101                 R.styleable.TextInputLayout_hintAnimationEnabled, true);
102 
103         if (a.hasValue(R.styleable.TextInputLayout_android_textColorHint)) {
104             mDefaultTextColor = mFocusedTextColor =
105                     a.getColorStateList(R.styleable.TextInputLayout_android_textColorHint);
106         }
107 
108         final int hintAppearance = a.getResourceId(
109                 R.styleable.TextInputLayout_hintTextAppearance, -1);
110         if (hintAppearance != -1) {
111             setHintTextAppearance(
112                     a.getResourceId(R.styleable.TextInputLayout_hintTextAppearance, 0));
113         }
114 
115         mErrorTextAppearance = a.getResourceId(R.styleable.TextInputLayout_errorTextAppearance, 0);
116         final boolean errorEnabled = a.getBoolean(R.styleable.TextInputLayout_errorEnabled, false);
117         a.recycle();
118 
119         setErrorEnabled(errorEnabled);
120 
121         if (ViewCompat.getImportantForAccessibility(this)
122                 == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
123             // Make sure we're important for accessibility if we haven't been explicitly not
124             ViewCompat.setImportantForAccessibility(this,
125                     ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
126         }
127 
128         ViewCompat.setAccessibilityDelegate(this, new TextInputAccessibilityDelegate());
129     }
130 
131     @Override
addView(View child, int index, ViewGroup.LayoutParams params)132     public void addView(View child, int index, ViewGroup.LayoutParams params) {
133         if (child instanceof EditText) {
134             setEditText((EditText) child);
135             super.addView(child, 0, updateEditTextMargin(params));
136         } else {
137             // Carry on adding the View...
138             super.addView(child, index, params);
139         }
140     }
141 
142     /**
143      * Set the typeface to use for the both the expanded and floating hint.
144      *
145      * @param typeface typeface to use, or {@code null} to use the default.
146      */
setTypeface(@ullable Typeface typeface)147     public void setTypeface(@Nullable Typeface typeface) {
148         mCollapsingTextHelper.setTypeface(typeface);
149     }
150 
setEditText(EditText editText)151     private void setEditText(EditText editText) {
152         // If we already have an EditText, throw an exception
153         if (mEditText != null) {
154             throw new IllegalArgumentException("We already have an EditText, can only have one");
155         }
156         mEditText = editText;
157 
158         // Use the EditText's typeface, and it's text size for our expanded text
159         mCollapsingTextHelper.setTypeface(mEditText.getTypeface());
160         mCollapsingTextHelper.setExpandedTextSize(mEditText.getTextSize());
161         mCollapsingTextHelper.setExpandedTextGravity(mEditText.getGravity());
162 
163         // Add a TextWatcher so that we know when the text input has changed
164         mEditText.addTextChangedListener(new TextWatcher() {
165             @Override
166             public void afterTextChanged(Editable s) {
167                 updateLabelVisibility(true);
168             }
169 
170             @Override
171             public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
172 
173             @Override
174             public void onTextChanged(CharSequence s, int start, int before, int count) {}
175         });
176 
177         // Use the EditText's hint colors if we don't have one set
178         if (mDefaultTextColor == null) {
179             mDefaultTextColor = mEditText.getHintTextColors();
180         }
181 
182         // If we do not have a valid hint, try and retrieve it from the EditText
183         if (TextUtils.isEmpty(mHint)) {
184             setHint(mEditText.getHint());
185             // Clear the EditText's hint as we will display it ourselves
186             mEditText.setHint(null);
187         }
188 
189         if (mErrorView != null) {
190             // Add some start/end padding to the error so that it matches the EditText
191             ViewCompat.setPaddingRelative(mErrorView, ViewCompat.getPaddingStart(mEditText),
192                     0, ViewCompat.getPaddingEnd(mEditText), mEditText.getPaddingBottom());
193         }
194 
195         // Update the label visibility with no animation
196         updateLabelVisibility(false);
197     }
198 
updateEditTextMargin(ViewGroup.LayoutParams lp)199     private LayoutParams updateEditTextMargin(ViewGroup.LayoutParams lp) {
200         // Create/update the LayoutParams so that we can add enough top margin
201         // to the EditText so make room for the label
202         LayoutParams llp = lp instanceof LayoutParams ? (LayoutParams) lp : new LayoutParams(lp);
203 
204         if (mTmpPaint == null) {
205             mTmpPaint = new Paint();
206         }
207         mTmpPaint.setTypeface(mCollapsingTextHelper.getTypeface());
208         mTmpPaint.setTextSize(mCollapsingTextHelper.getCollapsedTextSize());
209         llp.topMargin = (int) -mTmpPaint.ascent();
210 
211         return llp;
212     }
213 
updateLabelVisibility(boolean animate)214     private void updateLabelVisibility(boolean animate) {
215         boolean hasText = mEditText != null && !TextUtils.isEmpty(mEditText.getText());
216         boolean isFocused = arrayContains(getDrawableState(), android.R.attr.state_focused);
217 
218         if (mDefaultTextColor != null && mFocusedTextColor != null) {
219             mCollapsingTextHelper.setExpandedTextColor(mDefaultTextColor.getDefaultColor());
220             mCollapsingTextHelper.setCollapsedTextColor(isFocused
221                     ? mFocusedTextColor.getDefaultColor()
222                     : mDefaultTextColor.getDefaultColor());
223         }
224 
225         if (hasText || isFocused) {
226             // We should be showing the label so do so if it isn't already
227             collapseHint(animate);
228         } else {
229             // We should not be showing the label so hide it
230             expandHint(animate);
231         }
232     }
233 
234     /**
235      * Returns the {@link android.widget.EditText} used for text input.
236      */
237     @Nullable
getEditText()238     public EditText getEditText() {
239         return mEditText;
240     }
241 
242     /**
243      * Set the hint to be displayed in the floating label
244      *
245      * @attr ref android.support.design.R.styleable#TextInputLayout_android_hint
246      */
setHint(@ullable CharSequence hint)247     public void setHint(@Nullable CharSequence hint) {
248         mHint = hint;
249         mCollapsingTextHelper.setText(hint);
250 
251         sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
252     }
253 
254     /**
255      * Returns the hint which is displayed in the floating label.
256      *
257      * @attr ref android.support.design.R.styleable#TextInputLayout_android_hint
258      */
259     @Nullable
getHint()260     public CharSequence getHint() {
261         return mHint;
262     }
263 
264     /**
265      * Sets the hint text color, size, style from the specified TextAppearance resource.
266      *
267      * @attr ref android.support.design.R.styleable#TextInputLayout_hintTextAppearance
268      */
setHintTextAppearance(@tyleRes int resId)269     public void setHintTextAppearance(@StyleRes int resId) {
270         mCollapsingTextHelper.setCollapsedTextAppearance(resId);
271         mFocusedTextColor = ColorStateList.valueOf(mCollapsingTextHelper.getCollapsedTextColor());
272 
273         if (mEditText != null) {
274             updateLabelVisibility(false);
275 
276             // Text size might have changed so update the top margin
277             LayoutParams lp = updateEditTextMargin(mEditText.getLayoutParams());
278             mEditText.setLayoutParams(lp);
279             mEditText.requestLayout();
280         }
281     }
282 
283     /**
284      * Whether the error functionality is enabled or not in this layout. Enabling this
285      * functionality before setting an error message via {@link #setError(CharSequence)}, will mean
286      * that this layout will not change size when an error is displayed.
287      *
288      * @attr ref android.support.design.R.styleable#TextInputLayout_errorEnabled
289      */
setErrorEnabled(boolean enabled)290     public void setErrorEnabled(boolean enabled) {
291         if (mErrorEnabled != enabled) {
292             if (mErrorView != null) {
293                 ViewCompat.animate(mErrorView).cancel();
294             }
295 
296             if (enabled) {
297                 mErrorView = new TextView(getContext());
298                 mErrorView.setTextAppearance(getContext(), mErrorTextAppearance);
299                 mErrorView.setVisibility(INVISIBLE);
300                 addView(mErrorView);
301 
302                 if (mEditText != null) {
303                     // Add some start/end padding to the error so that it matches the EditText
304                     ViewCompat.setPaddingRelative(mErrorView, ViewCompat.getPaddingStart(mEditText),
305                             0, ViewCompat.getPaddingEnd(mEditText), mEditText.getPaddingBottom());
306                 }
307             } else {
308                 removeView(mErrorView);
309                 mErrorView = null;
310             }
311             mErrorEnabled = enabled;
312         }
313     }
314 
315     /**
316      * Returns whether the error functionality is enabled or not in this layout.
317      *
318      * @attr ref android.support.design.R.styleable#TextInputLayout_errorEnabled
319      *
320      * @see #setErrorEnabled(boolean)
321      */
isErrorEnabled()322     public boolean isErrorEnabled() {
323         return mErrorEnabled;
324     }
325 
326     /**
327      * Sets an error message that will be displayed below our {@link EditText}. If the
328      * {@code error} is {@code null}, the error message will be cleared.
329      * <p>
330      * If the error functionality has not been enabled via {@link #setErrorEnabled(boolean)}, then
331      * it will be automatically enabled if {@code error} is not empty.
332      *
333      * @param error Error message to display, or null to clear
334      *
335      * @see #getError()
336      */
setError(@ullable CharSequence error)337     public void setError(@Nullable CharSequence error) {
338         if (!mErrorEnabled) {
339             if (TextUtils.isEmpty(error)) {
340                 // If error isn't enabled, and the error is empty, just return
341                 return;
342             }
343             // Else, we'll assume that they want to enable the error functionality
344             setErrorEnabled(true);
345         }
346 
347         if (!TextUtils.isEmpty(error)) {
348             ViewCompat.setAlpha(mErrorView, 0f);
349             mErrorView.setText(error);
350             ViewCompat.animate(mErrorView)
351                     .alpha(1f)
352                     .setDuration(ANIMATION_DURATION)
353                     .setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)
354                     .setListener(new ViewPropertyAnimatorListenerAdapter() {
355                         @Override
356                         public void onAnimationStart(View view) {
357                             view.setVisibility(VISIBLE);
358                         }
359                     })
360                     .start();
361 
362             // Set the EditText's background tint to the error color
363             ViewCompat.setBackgroundTintList(mEditText,
364                     ColorStateList.valueOf(mErrorView.getCurrentTextColor()));
365         } else {
366             if (mErrorView.getVisibility() == VISIBLE) {
367                 ViewCompat.animate(mErrorView)
368                         .alpha(0f)
369                         .setDuration(ANIMATION_DURATION)
370                         .setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR)
371                         .setListener(new ViewPropertyAnimatorListenerAdapter() {
372                             @Override
373                             public void onAnimationEnd(View view) {
374                                 view.setVisibility(INVISIBLE);
375                             }
376                         }).start();
377 
378                 // Restore the 'original' tint, using colorControlNormal and colorControlActivated
379                 final TintManager tintManager = TintManager.get(getContext());
380                 ViewCompat.setBackgroundTintList(mEditText,
381                         tintManager.getTintList(R.drawable.abc_edit_text_material));
382             }
383         }
384 
385         sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
386     }
387 
388     /**
389      * Returns the error message that was set to be displayed with
390      * {@link #setError(CharSequence)}, or <code>null</code> if no error was set
391      * or if error displaying is not enabled.
392      *
393      * @see #setError(CharSequence)
394      */
395     @Nullable
getError()396     public CharSequence getError() {
397         if (mErrorEnabled && mErrorView != null && mErrorView.getVisibility() == VISIBLE) {
398             return mErrorView.getText();
399         }
400         return null;
401     }
402 
403     /**
404      * Returns whether any hint state changes, due to being focused or non-empty text, are
405      * animated.
406      *
407      * @see #setHintAnimationEnabled(boolean)
408      *
409      * @attr ref android.support.design.R.styleable#TextInputLayout_hintAnimationEnabled
410      */
isHintAnimationEnabled()411     public boolean isHintAnimationEnabled() {
412         return mHintAnimationEnabled;
413     }
414 
415     /**
416      * Set whether any hint state changes, due to being focused or non-empty text, are
417      * animated.
418      *
419      * @see #isHintAnimationEnabled()
420      *
421      * @attr ref android.support.design.R.styleable#TextInputLayout_hintAnimationEnabled
422      */
setHintAnimationEnabled(boolean enabled)423     public void setHintAnimationEnabled(boolean enabled) {
424         mHintAnimationEnabled = enabled;
425     }
426 
427     @Override
draw(Canvas canvas)428     public void draw(Canvas canvas) {
429         super.draw(canvas);
430         mCollapsingTextHelper.draw(canvas);
431     }
432 
433     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)434     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
435         super.onLayout(changed, left, top, right, bottom);
436 
437         if (mEditText != null) {
438             final int l = mEditText.getLeft() + mEditText.getCompoundPaddingLeft();
439             final int r = mEditText.getRight() - mEditText.getCompoundPaddingRight();
440 
441             mCollapsingTextHelper.setExpandedBounds(l,
442                     mEditText.getTop() + mEditText.getCompoundPaddingTop(),
443                     r, mEditText.getBottom() - mEditText.getCompoundPaddingBottom());
444 
445             // Set the collapsed bounds to be the the full height (minus padding) to match the
446             // EditText's editable area
447             mCollapsingTextHelper.setCollapsedBounds(l, getPaddingTop(),
448                     r, bottom - top - getPaddingBottom());
449 
450             mCollapsingTextHelper.recalculate();
451         }
452     }
453 
454     @Override
refreshDrawableState()455     public void refreshDrawableState() {
456         super.refreshDrawableState();
457         // Drawable state has changed so see if we need to update the label
458         updateLabelVisibility(ViewCompat.isLaidOut(this));
459     }
460 
collapseHint(boolean animate)461     private void collapseHint(boolean animate) {
462         if (mAnimator != null && mAnimator.isRunning()) {
463             mAnimator.cancel();
464         }
465         if (animate && mHintAnimationEnabled) {
466             animateToExpansionFraction(1f);
467         } else {
468             mCollapsingTextHelper.setExpansionFraction(1f);
469         }
470     }
471 
expandHint(boolean animate)472     private void expandHint(boolean animate) {
473         if (mAnimator != null && mAnimator.isRunning()) {
474             mAnimator.cancel();
475         }
476         if (animate && mHintAnimationEnabled) {
477             animateToExpansionFraction(0f);
478         } else {
479             mCollapsingTextHelper.setExpansionFraction(0f);
480         }
481     }
482 
animateToExpansionFraction(final float target)483     private void animateToExpansionFraction(final float target) {
484         if (mCollapsingTextHelper.getExpansionFraction() == target) {
485             return;
486         }
487         if (mAnimator == null) {
488             mAnimator = ViewUtils.createAnimator();
489             mAnimator.setInterpolator(AnimationUtils.LINEAR_INTERPOLATOR);
490             mAnimator.setDuration(ANIMATION_DURATION);
491             mAnimator.setUpdateListener(new ValueAnimatorCompat.AnimatorUpdateListener() {
492                 @Override
493                 public void onAnimationUpdate(ValueAnimatorCompat animator) {
494                     mCollapsingTextHelper.setExpansionFraction(animator.getAnimatedFloatValue());
495                 }
496             });
497         }
498         mAnimator.setFloatValues(mCollapsingTextHelper.getExpansionFraction(), target);
499         mAnimator.start();
500     }
501 
getThemeAttrColor(int attr)502     private int getThemeAttrColor(int attr) {
503         TypedValue tv = new TypedValue();
504         if (getContext().getTheme().resolveAttribute(attr, tv, true)) {
505             return tv.data;
506         } else {
507             return Color.MAGENTA;
508         }
509     }
510 
511     private class TextInputAccessibilityDelegate extends AccessibilityDelegateCompat {
512         @Override
onInitializeAccessibilityEvent(View host, AccessibilityEvent event)513         public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
514             super.onInitializeAccessibilityEvent(host, event);
515             event.setClassName(TextInputLayout.class.getSimpleName());
516         }
517 
518         @Override
onPopulateAccessibilityEvent(View host, AccessibilityEvent event)519         public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) {
520             super.onPopulateAccessibilityEvent(host, event);
521 
522             final CharSequence text = mCollapsingTextHelper.getText();
523             if (!TextUtils.isEmpty(text)) {
524                 event.getText().add(text);
525             }
526         }
527 
528         @Override
onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info)529         public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
530             super.onInitializeAccessibilityNodeInfo(host, info);
531             info.setClassName(TextInputLayout.class.getSimpleName());
532 
533             final CharSequence text = mCollapsingTextHelper.getText();
534             if (!TextUtils.isEmpty(text)) {
535                 info.setText(text);
536             }
537             if (mEditText != null) {
538                 info.setLabelFor(mEditText);
539             }
540             final CharSequence error = mErrorView != null ? mErrorView.getText() : null;
541             if (!TextUtils.isEmpty(error)) {
542                 info.setContentInvalid(true);
543                 info.setError(error);
544             }
545         }
546     }
547 
arrayContains(int[] array, int value)548     private static boolean arrayContains(int[] array, int value) {
549         for (int v : array) {
550             if (v == value) {
551                 return true;
552             }
553         }
554         return false;
555     }
556 }