On 1st April 2015 I published a joke app to Google Play named Dirty Phrasebook which is based upon Monty Python’s Dirty Hungarian Phrasebook sketch. In this series of articles we’ll take a look in to the code (which will be open-sourced along with the final article). In this article we’ll look at the custom View that we use to handle user input.
The decision to create a custom view in this instance was because there are some animations and behaviours which need to be applied to a group of controls, so by using an aggregate custom View we can apply these easily to a ViewGroup – in this case we’re using a CardView from the CardView support library so that we can get a nice shadow on the ViewGroup.
We’ve already taken a look at the layout used for this, so we need to inflate it obtain references to some of the component controls:
public class InputView extends CardView { private EditText input; private View clearInput; private View inputDone; private View focusHolder; private GestureDetectorCompat gestureDetector; private float offsetHeight; private int duration; public InputView(Context context, AttributeSet attrs) { super(context, attrs); initialise(context, attrs, 0); } public InputView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initialise(context, attrs, defStyleAttr); } private void initialise(Context context, AttributeSet attrs, int defStyleAttr) { TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.InputView, defStyleAttr, 0); offsetHeight = a.getDimension(R.styleable.InputView_offset, 0f); duration = a.getInteger(R.styleable.InputView_duration, 0); a.recycle(); gestureDetector = new GestureDetectorCompat(context, new GestureListener()); } @Override protected void onFinishInflate() { super.onFinishInflate(); LayoutInflater inflater = LayoutInflater.from(getContext()); inflater.inflate(R.layout.input_view, this, true); input = (EditText) findViewById(R.id.input); focusHolder = findViewById(R.id.focus_holder); clearInput = findViewById(R.id.clear_input); inputDone = findViewById(R.id.input_done); input.setOnFocusChangeListener(new FocusChangeListener()); input.setOnEditorActionListener(new InputCompleteListener()); input.addTextChangedListener(new InputChangeListener()); inputDone.setOnClickListener(new DoneListener()); input.setOnTouchListener(new OnTouchListener() { @Override public boolean onTouch(View v, MotionEvent event) { return inputDone.getVisibility() == VISIBLE && gestureDetector.onTouchEvent(event); } }); clearInput.setOnClickListener(new ClearListener()); } }
There’s a fair bit happening here already. Either constructor will call the initialise()
method which is responsible for getting a couple of custom attribute values from the InputView element within the parent layout. One of these is to indicate the distance that we want to move the InputView when we enter Input mode, and the second controls the animation duration. It also creates a GestureListener – more on this later.
onFinishedInflate()
gets called once the parent layout inflation has completes, and in here we inflate the layout for this control, obtain references to the component Views, and set a number of different listeners on those Views.
The first of these is FocusChangeListener which is called whenever the EditText control receives or loses focus:
private class FocusChangeListener implements View.OnFocusChangeListener { @Override public void onFocusChange(View v, boolean hasFocus) { if (v.equals(input)) { if (hasFocus) { startInputMode(); } else { endInputMode(); } } } }
This is simple enough: We just enter or exit input mode depending on the focus state of this control.
Next we have InputCompleteListener which gets called for various actions on the EditText control:
private class InputCompleteListener implements TextView.OnEditorActionListener { @Override public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { if (actionId == EditorInfo.IME_ACTION_DONE) { endInputMode(); return true; } return false; } }
In our case we’re only interested in when the user clicks on the “Done” button in the soft keyboard and we end input mode when this occurs.
Next we have InputChangeListener which gets called whenever the text within the EditText View changes:
private class InputChangeListener implements android.text.TextWatcher { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { } @Override public void afterTextChanged(Editable s) { clearInput.setVisibility(TextUtils.isEmpty(s) ? View.INVISIBLE : View.VISIBLE); } }
The purpose here is to control the visibility of a button which allows the user to clear the input. There’s no point in showing this if the EditText is already empty, so we detect changes in the EditText and change the state of the clear control accordingly.
Next we have DoneListener which gets called when the user clicks a “Done” button which overlays the EditText (many thanks to Sebastiano Poggi for suggesting this):
private class DoneListener implements OnClickListener { @Override public void onClick(View v) { endInputMode(); } }
This provides one of a number of different methods of exiting input mode – providing lots of distinct methods of the user exiting input mode makes discoverability much better!
Next up is the GestureListener that we created earlier:
private final class GestureListener extends GestureDetector.SimpleOnGestureListener { @Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { if (isSwipeDown(velocityX, velocityY)) { endInputMode(); } return false; } private boolean isSwipeDown(float velocityX, float velocityY) { return velocityY > 0 && velocityY > velocityX * SWIPE_DIRECTION_FACTOR; } }
This provides yet another mechanism for the user to exit input mode by swiping downwards (many thanks to Benoit Duffez for the suggestion). We check that the y component of the fling is both positive and exceeds the x component by a factor of 5 to constitute a downward swipe.
The final listener is ClearListener which gets called when the user taps the clear button which we mentioned earlier:
private static final String EMPTY_STRING = ""; private class ClearListener implements OnClickListener { @Override public void onClick(View v) { input.setText(EMPTY_STRING); inputChanged(EMPTY_STRING); } }
This looks pretty straightforward until we think of the bigger picture. InputView encapsulate a set of Views which are specific to the EditText and input mode. However we also need to communicate with the outside world because there are other Views in the main layout which may also need to be updated when input mode changes. We therefore need a mechanism to communicate with external components, and this is done using an custom Listener:
public class InputView extends CardView { private Setlisteners = new HashSet<>(); . . . public void registerInputListener(InputListener listener) { listeners.add(listener); } public void unregisterInputListener(InputListener listener) { listeners.remove(listener); } public interface InputListener { void inputChanged(String input); Animator startInputMode(); Animator endInputMode(boolean hasValue); } }
We can now look at the inputChanged()
method that is called by the ClearListener to see that this is a mechanism for communicating changes to stakeholders:
public class InputView extends CardView { . . . private void inputChanged(String inputString) { for (InputListener listener : listeners) { listener.inputChanged(inputString); } } . . . }
There are a couple of methods which have been called a few times which require some explanation:
public class InputView extends CardView { public static final String TRANSLATION_Y = "TranslationY"; public static final String ALPHA = "alpha"; . . . private void startInputMode() { AnimatorSet animatorSet = new AnimatorSet(); List<Animator> animators = new ArrayList<>(); inputDone.setVisibility(VISIBLE); animators.add(ObjectAnimator.ofFloat(this, TRANSLATION_Y, 0, -offsetHeight)); animators.add(ObjectAnimator.ofFloat(inputDone, ALPHA, 0f, 1f)); for (InputListener listener : listeners) { animators.add(listener.startInputMode()); } animatorSet.playTogether(animators); animatorSet.setDuration(duration); animatorSet.start(); } public void endInputMode() { focusHolder.requestFocus(); AnimatorSet myAnimators = new AnimatorSet(); AnimatorSet listenerAnimators = new AnimatorSet(); myAnimators.playTogether(ObjectAnimator.ofFloat(this, TRANSLATION_Y, -offsetHeight, 0), ObjectAnimator.ofFloat(inputDone, ALPHA, 1f, 0f)); myAnimators.setDuration(duration); List<Animator> animators = new ArrayList<>(); for (InputListener listener : listeners) { animators.add(listener.endInputMode(!TextUtils.isEmpty(input.getText()))); } listenerAnimators.playTogether(animators); listenerAnimators.setDuration(duration); myAnimators.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); InputMethodManager imm = (InputMethodManager) getContext().getSystemService( Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(input.getWindowToken(), 0); inputChanged(getText()); inputDone.setVisibility(INVISIBLE); } }); AnimatorSet sequence = new AnimatorSet(); sequence.playSequentially(myAnimators, listenerAnimators); sequence.start(); } . . . }
These construct the property animations that will be played when entering and exiting input mode. I won’t do a deep dive in to the property animations here because there is an upcoming series which will cover property animations in much greater depth – watch this space! There are, however, a couple of important things that occur in endInputMode()
.
The first is that we request the focus for focusHolder
. We mentioned focusHolder
in a previous article: It is a zero sized View which enables us to hold focus away from the EditText control and, because it is not an EditText itself, will cause the IME to be hidden when it receives focus. So, by requesting focus to this View, we are controlling the IME visibility.
The second important thing is notifying the InputLister(s) that the input text has changed once the user exits input mode. This enables us to trigger an update of the translation text which is extrinsic to InputView.
We have a getter/setter which we’ll need to to change the text:
public class InputView extends CardView { . . . public String getText() { return input.getText().toString(); } public void setText(CharSequence text) { input.setText(text); } public int getAnimationDuration() { return duration; } }
In the next article we’ll look at the code for our MainActivity and see how that integrates with InputView to connect up all of our business logic.
The source code for this series is available here.
I am deeply indebted to my fantastic team of volunteer translators who generously gave their time and language skills to make this project sooo much better. They are Sebastiano Poggi (Italian), Zvonko Grujić (Croatian), Conor O’Donnell (Gaelic), Stefan Hoth (German), Hans Petter Eide (Norwegian), Wiebe Elsinga (Dutch), Imanol Pérez Iriarte (Spanish), Adam Graves (Malay), Teo Ramone (Greek), Mattias Isegran Bergander (Swedish), Morten Grouleff (Danish), George Medve (Hungarian), Anup Cowkur (Hindi), Draško Sarić (Serbian), Polson Keeratibumrungpong (Thai), Benoit Duffez (French), and Vasily Sochinsky (Russian).
© 2015, Mark Allison. All rights reserved.
Copyright © 2015 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.
Hi,
You inflated the InputView layout in the onFinishInflate, but it isn’t better to inflate in the constructor? So when the parent layout inflate itself, it already know their children measures?
If you do that you then cannot add it to the parent because the parent inflation has not completed at that point. That’s why we have to wait for the parent layout inflation to complete.