Friday, March 15, 2013

TextView action on disappearing soft keyboard event

The task is to reformat a TextView when the user hides the soft keyboard (ime). It should correct it as a proper money amount: two decimal digits, decimal separator. Of course there is no way on Android to query if the keyboard is going to hide. But there are a few ways, the keyboard can be hidden by the user: backpress when it is visible, focus outside of the TextView, change container TabFragment to other Fragment. This is my solution for this:
/**
 * Simple subclass of an EditText that allows to capture and dispatch BackPress, when the EditText has the focus.
 * This event is later used to finalise formatted text in the amount field (append trailing ".00" if necessary).
 * I could not find other way to detect keyboard is about to hide.
 * Created by: mikinw
 */
@SuppressWarnings("UnusedDeclaration")
public class HwKeySensibleEditText extends EditText {

    public HwKeySensibleEditText(Context context) {
        super(context);
    }

    public HwKeySensibleEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public HwKeySensibleEditText(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    @Override
    public boolean onKeyPreIme(int keyCode, KeyEvent event) {
        return super.dispatchKeyEvent(event);
    }

    @Override
    public Parcelable onSaveInstanceState() {
        // This forces a focus changed event to allow {@link AmountReformatter} to do
        // validation when user hides keyboard
        onFocusChanged(false, FOCUS_BACKWARD, null);
        return super.onSaveInstanceState();
    }
}

This class is responsible to reformat the text inside the TextView. It handles the forced focus changed event also. I didn't delete the references to the outer Constants class, but you can find out, what those values should be.
/**
 * Controller class for formatting amount field.
 * Handles all automatic correction that is needed for the amount field.
 * In the constructor it gets a {@link HwKeySensibleEditText} - which is a slightly modified EditText.
 * Auto correction has two steps:
 * - as to user types (add pound sign, don't allow multiple zeros at the beginning, etc. please check
 *   correctOnTheFlyText() function for this. And related unit tests)
 * - on focus lost (add ".00" as necessary. please check correctFinalAmount() function and related unit tests)
 *
 * Also 2 listener can be attached also: OnValueChangedListener and OnEnterLeaveListener.
 * Created by: mikinw
 */
public class AmountReformatter implements TextWatcher, View.OnKeyListener, View.OnFocusChangeListener {

    private HwKeySensibleEditText mAmountTextView;
    private OnValueChangedListener mValueChangedListener;
    private OnEnterLeaveListener mEnterLeaveListener;
    private static final int MAX_INTEGER_DIGITS = 15;

    public interface OnValueChangedListener {
        void onValueChanged (String value);
    }

    public interface OnEnterLeaveListener {
        void onEnter();
    }

    private AmountReformatter(HwKeySensibleEditText amountTextView,
                              OnValueChangedListener valueChangedListener,
                              OnEnterLeaveListener enterLeaveListener) {
        if (amountTextView == null) {
            throw new IllegalArgumentException("amountTextView can't be null");
        }
        mAmountTextView = amountTextView;
        mValueChangedListener = valueChangedListener;
        mEnterLeaveListener = enterLeaveListener;
    }

    private boolean mDeleting = false;
    @Override
    public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {
        mDeleting = after < count && mAmountTextView.hasFocus();
    }

    @Override
    public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) {
    }

    @Override
    public void afterTextChanged(Editable editable) {
        if(mAmountTextView.hasFocus()){
            //update onthefly only if tv has focus, cause correctFinalAmount will be overwrited
            mAmountTextView.removeTextChangedListener(this);
            final String curValue = editable.toString();
            String corrected = null;
            if(mDeleting && curValue.length() == 0){
                //don't allow user to delete pound char
                corrected = Constants.POUND_STRING;
            }else{
                corrected = correctOnTheFlyText(curValue);
            }
            // prevent infinite recursive call
            if ( !corrected.equals(editable.toString())) {
                editable.replace(0, editable.length(), corrected);
            }
            mAmountTextView.addTextChangedListener(this);
        }

        if (mValueChangedListener != null) {
            mValueChangedListener.onValueChanged(mAmountTextView.getText().toString());
        }
    }


    @Override
    public boolean onKey(View view, int keyCode, KeyEvent keyEvent) {
        if (keyCode == KeyEvent.KEYCODE_BACK && keyEvent.getAction() == KeyEvent.ACTION_UP) {
            mAmountTextView.clearFocus();
        } else if (keyCode == KeyEvent.KEYCODE_ENTER && keyEvent.getAction() == KeyEvent.ACTION_UP) {
            final InputMethodManager imm =
                    (InputMethodManager)view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
            imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
            mAmountTextView.clearFocus();
            return false;
        }
        return false;
    }

    @Override
    public void onFocusChange(View view, boolean focusGained) {
        final String txtAmount = mAmountTextView.getText().toString();

        if (focusGained) {
            if (TextUtils.isEmpty(txtAmount)) {
                mAmountTextView.setText(Constants.POUND_STRING);
            }else{
                //needs to remove thousands separator
                mAmountTextView.setText(correctOnTheFlyText(txtAmount));
            }
            //move cursor to the end
            mAmountTextView.setSelection(mAmountTextView.getText().length());

            mAmountTextView.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null);
            if (mEnterLeaveListener != null) {
                mEnterLeaveListener.onEnter();
            }
        } else {
            //restore texthint if there is only pound char
            mAmountTextView.setText(Constants.POUND_STRING.equals(txtAmount)
                                            ? ""
                                            : correctFinalAmount(txtAmount));
        }
    }

    /**
     * Appends ".00" as needed or truncates to have exactly 2 decimals. Does not check other validity of the original
     * string (eg. will accept alphaliterals). It it's value is zero, then returns empty string.
     *
     * @param original amount string that needs to be modified according to the above rules
     * @return modified amount text containing decimal separator and two digits after that
     */
    @SuppressWarnings("MagicCharacter")
    static String correctFinalAmount(String original) {
        // if no . add . to end
        // fill the end to have 2 decimals
        if (TextUtils.isEmpty(original)) {
            return original;
        }

        final String withoutPoundSign = original.charAt(0) == Constants.POUND_CHAR ? original.substring(1) : original;
        BigDecimal decimal;
        try {
            decimal = new BigDecimal(withoutPoundSign);
            if (decimal.compareTo(new BigDecimal(0)) == 0) {
                return "";
            }
        } catch (NumberFormatException nfe) {
            return "";
        }

        decimal = decimal.setScale(2, RoundingMode.DOWN);
        final DecimalFormat df = new DecimalFormat();
        df.setDecimalFormatSymbols(Constants.FORCED_FORMAT_SYMBOLS);
        df.setGroupingSize(3);
        df.setGroupingUsed(true);
        df.setMaximumFractionDigits(2);
        df.setMinimumFractionDigits(2);
        df.setPositivePrefix(Constants.POUND_STRING);
        return df.format(decimal);

    }



    /**
     * Filters out not allowed characters (eg. letters, symbols). Also truncates the leading zeros, but does allow to
     * have "0.". Also adds pound symbol to the beginning. Truncates ending numbers after the first "." character to
     * have at most 2 of them.
     * @param original String that has to be formatted, corrected
     * @return formatted string according to the rules above
     */
    static String correctOnTheFlyText(String original) {
        if (TextUtils.isEmpty(original)) {
            return original;
        }
        final StringBuilder preDot = new StringBuilder(original.length() + 1);
        final StringBuilder postDot = new StringBuilder(original.length() + 1);
        boolean leadingZero = false;
        boolean dot = false;

        int i = 0;
        final int to = original.length();
        for (; i < to; i++) {
            final char ch = original.charAt(i);
            if (ch == '0') {
                leadingZero = true;
            } else if (ch > '0' && ch <= '9') {
                break;
            } else if (ch == Constants.FORCED_FORMAT_SYMBOLS.getDecimalSeparator()) {
                dot = true;
                leadingZero = true;
                break;
            }
        }

        for (; i < to; i++) {
            final char ch = original.charAt(i);
            if (ch >= '0' && ch <= '9') {
                if (preDot.length() < MAX_INTEGER_DIGITS) {
                    preDot.append(ch);
                }
            }else if(ch == Constants.FORCED_FORMAT_SYMBOLS.getGroupingSeparator()){
                //thousands separator ignor, will be added later
            } else if (ch == Constants.FORCED_FORMAT_SYMBOLS.getDecimalSeparator()) {
                dot = true;
                break;
            }
        }

        for (int decimalCount = 0; i < to && decimalCount < 2; i++) {
            final char c = original.charAt(i);
            if (c >= '0' && c <= '9') {
                postDot.append(c);
                decimalCount++;
            }
        }

        if (leadingZero && preDot.length() == 0) {
            preDot.insert(0, '0');
        }
        if (dot) {
            preDot.append(Constants.FORCED_FORMAT_SYMBOLS.getDecimalSeparator());
        }

        //add thousands separator
        preDot.append(postDot);

        preDot.insert(0, Constants.POUND_CHAR);

        return preDot.toString();

    }

    /** Static initiliser */
    public static void setListenersFor(HwKeySensibleEditText amountTextView,
                                       OnValueChangedListener valueChangedListener,
                                       OnEnterLeaveListener enterListener) {
        final AmountReformatter reformatter = new AmountReformatter(amountTextView, valueChangedListener, enterListener);
        amountTextView.setOnFocusChangeListener(reformatter);
        amountTextView.addTextChangedListener(reformatter);
        amountTextView.setOnKeyListener(reformatter);
    }
}

No comments: