From 45d39b2df8f3f0e64a9ccaa3e992cc04e492142e Mon Sep 17 00:00:00 2001 From: Gavriil Sitnikov Date: Tue, 14 Jun 2016 00:40:10 +0300 Subject: [PATCH] Typefaced views logic in release candidate state --- .../fragments/ViewControllerFragment.java | 11 +- .../roboswag/components/utils/Typefaces.java | 105 +++++ .../roboswag/components/utils/UiUtils.java | 336 +++++++++++++++ .../components/views/TypefacedEditText.java | 291 +++++++++++++ .../components/views/TypefacedTextView.java | 400 ++++++++++++++++++ .../views/internal/AttributesCheckUtils.java | 118 ++++++ src/main/res/values/attrs.xml | 22 + 7 files changed, 1277 insertions(+), 6 deletions(-) create mode 100644 src/main/java/ru/touchin/roboswag/components/utils/Typefaces.java create mode 100644 src/main/java/ru/touchin/roboswag/components/utils/UiUtils.java create mode 100644 src/main/java/ru/touchin/roboswag/components/views/TypefacedEditText.java create mode 100644 src/main/java/ru/touchin/roboswag/components/views/TypefacedTextView.java create mode 100644 src/main/java/ru/touchin/roboswag/components/views/internal/AttributesCheckUtils.java create mode 100644 src/main/res/values/attrs.xml diff --git a/src/main/java/ru/touchin/roboswag/components/navigation/fragments/ViewControllerFragment.java b/src/main/java/ru/touchin/roboswag/components/navigation/fragments/ViewControllerFragment.java index 553ff9e..ee41c8c 100644 --- a/src/main/java/ru/touchin/roboswag/components/navigation/fragments/ViewControllerFragment.java +++ b/src/main/java/ru/touchin/roboswag/components/navigation/fragments/ViewControllerFragment.java @@ -21,6 +21,7 @@ package ru.touchin.roboswag.components.navigation.fragments; import android.content.Context; import android.os.Bundle; +import android.os.Handler; import android.os.Parcel; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -58,15 +59,13 @@ public abstract class ViewControllerFragment TYPEFACES_MAP = new HashMap<>(); + + /** + * Returns {@link Typeface} by name from assets 'fonts' folder. + * + * @param context Context of assets where typeface file stored in; + * @param name Full name of typeface (without extension, e.g. 'Roboto-Regular'); + * @return {@link Typeface} from assets. + */ + @NonNull + public static Typeface getByName(@NonNull final Context context, @NonNull final String name) { + synchronized (TYPEFACES_MAP) { + Typeface result = TYPEFACES_MAP.get(name); + if (result == null) { + final AssetManager assetManager = context.getAssets(); + result = Typeface.DEFAULT; + try { + final List fonts = Arrays.asList(assetManager.list("fonts")); + if (fonts.contains(name + ".ttf")) { + result = Typeface.createFromAsset(assetManager, "fonts/" + name + ".ttf"); + } else if (fonts.contains(name + ".otf")) { + result = Typeface.createFromAsset(assetManager, "fonts/" + name + ".otf"); + } else { + Lc.assertion("Can't find .otf or .ttf file in assets folder 'fonts' with name: " + name); + } + } catch (final IOException exception) { + Lc.assertion(new ShouldNotHappenException("Can't get font " + name + '.' + + "Did you forget to create assets folder named 'fonts'?", exception)); + } + TYPEFACES_MAP.put(name, result); + } + return result; + } + } + + /** + * Returns {@link Typeface} by name from assets 'fonts' folder. + * + * @param context Context of assets where typeface file stored in; + * @param attrs Attributes of view to get font from; + * @param styleableId Id of attribute set (e.g. {@link ru.touchin.roboswag.components.R.styleable#TypefacedTextView}); + * @param attributeId Id of attribute (e.g. {@link ru.touchin.roboswag.components.R.styleable#TypefacedTextView_customTypeface}); + * @return {@link Typeface} from assets. + */ + @NonNull + public static Typeface getFromAttributes(@NonNull final Context context, @NonNull final AttributeSet attrs, + @StyleableRes final int[] styleableId, @StyleableRes final int attributeId) { + final TypedArray typedArray = context.obtainStyledAttributes(attrs, styleableId); + final String customTypeface = typedArray.getString(attributeId); + typedArray.recycle(); + if (customTypeface != null) { + return getByName(context, customTypeface); + } + Lc.w("Couldn't find custom typeface. Returns default"); + return Typeface.DEFAULT; + } + + private Typefaces() { + } + +} \ No newline at end of file diff --git a/src/main/java/ru/touchin/roboswag/components/utils/UiUtils.java b/src/main/java/ru/touchin/roboswag/components/utils/UiUtils.java new file mode 100644 index 0000000..2df193d --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/components/utils/UiUtils.java @@ -0,0 +1,336 @@ +/* + * Copyright (c) 2015 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.components.utils; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.IdRes; +import android.support.annotation.LayoutRes; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.Display; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; + +import java.util.concurrent.atomic.AtomicInteger; + +import rx.functions.Action0; + +/** + * Created by Gavriil Sitnikov on 13/11/2015. + * General utilities related to UI (Inflation, Views, Metrics, Activities etc.). + */ +public final class UiUtils { + + /** + * Delay to let user view rippleeffect before screen changed. + */ + public static final long RIPPLE_EFFECT_DELAY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? 150 : 0; + + private static final Handler RIPPLE_HANDLER = new Handler(Looper.getMainLooper()); + + /** + * Method to inflate view with right layout parameters based on container and add inflated view as a child to it. + * + * @param layoutId Id of layout resource; + * @param parent Container to rightly resolve layout parameters of view in XML; + * @return Inflated view. + */ + @NonNull + public static View inflateAndAdd(@LayoutRes final int layoutId, @NonNull final ViewGroup parent) { + LayoutInflater.from(parent.getContext()).inflate(layoutId, parent, true); + return parent.getChildAt(parent.getChildCount() - 1); + } + + /** + * Method to inflate view with right layout parameters based on container but not adding inflated view as a child to it. + * + * @param layoutId Id of layout resource; + * @param parent Container to rightly resolve layout parameters of view in XML; + * @return Inflated view. + */ + @NonNull + public static View inflate(@LayoutRes final int layoutId, @NonNull final ViewGroup parent) { + return LayoutInflater.from(parent.getContext()).inflate(layoutId, parent, false); + } + + /** + * Sets click listener to view. On click it will call something after delay. + * + * @param targetView View to set click listener to; + * @param onClickListener Click listener; + * @param delay Delay after which click listener will be called. + */ + public static void setOnRippleClickListener(@NonNull final View targetView, @Nullable final Action0 onClickListener, final long delay) { + setOnRippleClickListener(targetView, onClickListener != null ? v -> onClickListener.call() : null, delay); + } + + /** + * Sets click listener to view. On click it will call something with {@link #RIPPLE_EFFECT_DELAY}. + * + * @param targetView View to set click listener to; + * @param onClickListener Click listener. + */ + public static void setOnRippleClickListener(@NonNull final View targetView, @Nullable final Action0 onClickListener) { + setOnRippleClickListener(targetView, onClickListener != null ? v -> onClickListener.call() : null, RIPPLE_EFFECT_DELAY); + } + + /** + * Sets click listener to view. On click it will call something with {@link #RIPPLE_EFFECT_DELAY}. + * + * @param targetView View to set click listener to; + * @param onClickListener Click listener. + */ + public static void setOnRippleClickListener(@NonNull final View targetView, @Nullable final View.OnClickListener onClickListener) { + setOnRippleClickListener(targetView, onClickListener, RIPPLE_EFFECT_DELAY); + } + + /** + * Sets click listener to view. On click it will call something after delay. + * + * @param targetView View to set click listener to; + * @param onClickListener Click listener; + * @param delay Delay after which click listener will be called. + */ + public static void setOnRippleClickListener(@NonNull final View targetView, + @Nullable final View.OnClickListener onClickListener, + final long delay) { + if (onClickListener == null) { + targetView.setOnClickListener(null); + return; + } + + final Runnable runnable = () -> { + if (targetView.getWindowVisibility() == View.VISIBLE) { + onClickListener.onClick(targetView); + } + }; + + targetView.setOnClickListener(v -> { + RIPPLE_HANDLER.removeCallbacksAndMessages(null); + RIPPLE_HANDLER.postDelayed(runnable, delay); + }); + } + + private UiUtils() { + } + + /** + * Utilities methods related to metrics. + */ + public static class OfMetrics { + + private static final int MAX_METRICS_TRIES_COUNT = 5; + + /** + * Returns right metrics with non-zero height/width. + * It is common bug when metrics are calling at {@link Application#onCreate()} method and it returns metrics with zero height/width. + * + * @param context {@link Context} of metrics; + * @return {@link DisplayMetrics}. + */ + @SuppressWarnings("BusyWait") + @NonNull + public static DisplayMetrics getDisplayMetrics(@NonNull final Context context) { + DisplayMetrics result = context.getResources().getDisplayMetrics(); + // it is needed to avoid bug with invalid metrics when user restore application from other application + int metricsTryNumber = 0; + while (metricsTryNumber < MAX_METRICS_TRIES_COUNT && (result.heightPixels <= 0 || result.widthPixels <= 0)) { + try { + Thread.sleep(500); + } catch (final InterruptedException ignored) { + return result; + } + result = context.getResources().getDisplayMetrics(); + metricsTryNumber++; + } + return result; + } + + /** + * Simply converts DP to pixels. + * + * @param context {@link Context} of metrics; + * @param sizeInDp Size in DP; + * @return Size in pixels. + */ + public static float dpToPixels(@NonNull final Context context, final float sizeInDp) { + return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, sizeInDp, getDisplayMetrics(context)); + } + + private OfMetrics() { + } + + } + + /** + * Utilities methods related to activities and it'sintents. + */ + public static class OfActivities { + + /** + * Returns action bar (on top like toolbar or appbar) common height (56dp). + * + * @param activity Activity of action bar; + * @return Height of action bar. + */ + public static int getActionBarHeight(@NonNull final Activity activity) { + return (int) OfMetrics.dpToPixels(activity, 56); + } + + /** + * Returns status bar (on top where system info is) common height. + * + * @param activity Activity of status bar; + * @return Height of status bar. + */ + public static int getStatusBarHeight(@NonNull final Activity activity) { + final int resourceId = activity.getResources().getIdentifier("status_bar_height", "dimen", "android"); + return resourceId > 0 ? activity.getResources().getDimensionPixelSize(resourceId) : 0; + } + + /** + * Returns navigation bar (on bottom where system buttons are) common height. + * Be aware that some devices have no software keyboard (check it by {@link #hasSoftKeys(Activity)}) but this method will return you + * size like they are showed. + * + * @param activity Activity of navigation bar; + * @return Height of navigation bar. + */ + public static int getNavigationBarHeight(@NonNull final Activity activity) { + if (hasSoftKeys(activity)) { + final int resourceId = activity.getResources().getIdentifier("navigation_bar_height", "dimen", "android"); + return resourceId > 0 ? activity.getResources().getDimensionPixelSize(resourceId) : 0; + } + return 0; + } + + /** + * Returns if device has software keyboard at navigation bar or not. + * + * @param activity Activity of navigation bar; + * @return True if software keyboard is showing at navigation bar. + */ + //http://stackoverflow.com/questions/14853039/how-to-tell-whether-an-android-device-has-hard-keys/14871974#14871974 + public static boolean hasSoftKeys(@NonNull final Activity activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + final Display display = activity.getWindowManager().getDefaultDisplay(); + + final DisplayMetrics realDisplayMetrics = new DisplayMetrics(); + display.getRealMetrics(realDisplayMetrics); + + final DisplayMetrics displayMetrics = new DisplayMetrics(); + display.getMetrics(displayMetrics); + + return (realDisplayMetrics.widthPixels - displayMetrics.widthPixels) > 0 + || (realDisplayMetrics.heightPixels - displayMetrics.heightPixels) > 0; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + final boolean hasMenuKey = ViewConfiguration.get(activity).hasPermanentMenuKey(); + final boolean hasBackKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK); + return !hasMenuKey && !hasBackKey; + } + return false; + } + + /** + * Returns if {@link Intent} could be handled by system. + * + * @param context {@link Context} of application; + * @param intent {@link Intent} to be handled; + * @return True if there are handlers for {@link Intent} (e.g. browser could handle URI intent). + */ + public static boolean isIntentAbleToHandle(@NonNull final Context context, @NonNull final Intent intent) { + return !context.getPackageManager().queryIntentActivities(intent, 0).isEmpty(); + } + + private OfActivities() { + } + + } + + /** + * Utilities methods related to views. + */ + public static class OfViews { + + private static final int GENERATED_ID_THRESHOLD = 0x00FFFFFF; + private static final AtomicInteger NEXT_GENERATED_ID = new AtomicInteger(1); + + /** + * Generates unique ID for view. See android {@link View#generateViewId()}. + * + * @return Unique ID. + */ + @IdRes + public static int generateViewId() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + return View.generateViewId(); + } + int result = 0; + boolean isGenerated = false; + while (!isGenerated) { + result = NEXT_GENERATED_ID.get(); + // aapt-generated IDs have the high byte nonzero; clamp to the range under that. + int newValue = result + 1; + if (newValue > GENERATED_ID_THRESHOLD) { + newValue = 1; // Roll over to 1, not 0. + } + if (NEXT_GENERATED_ID.compareAndSet(result, newValue)) { + isGenerated = true; + } + } + return result; + } + + /** + * Returns string representation of {@link View}'s ID. + * + * @param view {@link View} to get ID from; + * @return Readable ID. + */ + @NonNull + public static String getViewIdString(@NonNull final View view) { + try { + return view.getResources().getResourceName(view.getId()); + } catch (final Resources.NotFoundException exception) { + return String.valueOf(view.getId()); + } + } + + private OfViews() { + } + + } + +} diff --git a/src/main/java/ru/touchin/roboswag/components/views/TypefacedEditText.java b/src/main/java/ru/touchin/roboswag/components/views/TypefacedEditText.java new file mode 100644 index 0000000..9d77804 --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/components/views/TypefacedEditText.java @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2015 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.components.views; + +import android.content.Context; +import android.content.res.TypedArray; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.AppCompatEditText; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; + +import java.util.ArrayList; +import java.util.List; + +import ru.touchin.roboswag.components.R; +import ru.touchin.roboswag.components.utils.Typefaces; +import ru.touchin.roboswag.components.views.internal.AttributesCheckUtils; +import ru.touchin.roboswag.core.log.Lc; + +/** + * Created by Gavriil Sitnikov on 18/07/2014. + * TextView that supports fonts from Typefaces class + */ + +/** + * Created by Gavriil Sitnikov on 18/07/2014. + * EditText that supports custom typeface and forces developer to specify if this view multiline or not. + * Also in debug mode it has common checks for popular bugs. + */ +@SuppressWarnings("PMD.ConstructorCallsOverridableMethod") +//ConstructorCallsOverridableMethod: it's ok as we need to setTypeface +public class TypefacedEditText extends AppCompatEditText { + + private static boolean inDebugMode; + + /** + * Enables debugging features like checking attributes on inflation. + */ + public static void setInDebugMode() { + inDebugMode = true; + } + + private boolean multiline; + + @Nullable + private OnTextChangedListener onTextChangedListener; + + public TypefacedEditText(@NonNull final Context context) { + super(context); + initialize(context, null); + } + + public TypefacedEditText(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs); + } + + public TypefacedEditText(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs); + } + + private void initialize(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super.setIncludeFontPadding(false); + initializeTextChangedListener(); + if (attrs != null) { + final TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TypefacedEditText); + setMultiline(typedArray.getBoolean(R.styleable.TypefacedEditText_isMultiline, false)); + if (!isInEditMode()) { + setTypeface(Typefaces.getFromAttributes(context, attrs, R.styleable.TypefacedEditText, R.styleable.TypefacedEditText_customTypeface)); + } + typedArray.recycle(); + if (inDebugMode) { + checkAttributes(context, attrs); + } + } + } + + private void checkAttributes(@NonNull final Context context, @NonNull final AttributeSet attrs) { + final List errors = new ArrayList<>(); + Boolean multiline = null; + TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TypefacedEditText); + AttributesCheckUtils.checkAttribute(typedArray, errors, R.styleable.TypefacedEditText_customTypeface, true, + "customTypeface required parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, R.styleable.TypefacedEditText_isMultiline, true, + "isMultiline required parameter"); + if (typedArray.hasValue(R.styleable.TypefacedEditText_isMultiline)) { + multiline = typedArray.getBoolean(R.styleable.TypefacedEditText_isMultiline, false); + } + typedArray.recycle(); + + try { + final Class androidRes = Class.forName("com.android.internal.R$styleable"); + + typedArray = context.obtainStyledAttributes(attrs, AttributesCheckUtils.getField(androidRes, "TextView")); + AttributesCheckUtils.checkRegularTextViewAttributes(typedArray, androidRes, errors, "isMultiline"); + checkEditTextSpecificAttributes(typedArray, androidRes, errors); + if (multiline != null) { + checkMultilineAttributes(typedArray, androidRes, errors, multiline); + } + } catch (final Exception exception) { + Lc.assertion(exception); + } + AttributesCheckUtils.handleErrors(this, errors); + typedArray.recycle(); + } + + private void checkEditTextSpecificAttributes(@NonNull final TypedArray typedArray, @NonNull final Class androidRes, + @NonNull final List errors) + throws NoSuchFieldException, IllegalAccessException { + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_typeface"), false, + "remove typeface and use customTypeface"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_textStyle"), false, + "remove textStyle and use customTypeface"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_fontFamily"), false, + "remove fontFamily and use customTypeface"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_singleLine"), false, + "remove singleLine and use isMultiline"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_includeFontPadding"), false, + "includeFontPadding forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_ellipsize"), false, + "ellipsize forbid parameter"); + + if (typedArray.hasValue(AttributesCheckUtils.getField(androidRes, "TextView_hint"))) { + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_textColorHint"), true, + "textColorHint required parameter if hint is not null"); + } + + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_textSize"), true, + "textSize required parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_inputType"), true, + "inputType required parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_imeOptions"), true, + "imeOptions required parameter"); + } + + private void checkMultilineAttributes(@NonNull final TypedArray typedArray, @NonNull final Class androidRes, + @NonNull final List errors, final boolean multiline) + throws NoSuchFieldException, IllegalAccessException { + if (multiline) { + if (typedArray.getInt(AttributesCheckUtils.getField(androidRes, "TextView_lines"), -1) == 1) { + errors.add("lines should be more than 1 if isMultiline is true"); + } + if (typedArray.getInt(AttributesCheckUtils.getField(androidRes, "TextView_maxLines"), -1) == 1) { + errors.add("maxLines should be more than 1 if isMultiline is true"); + } + if (!typedArray.hasValue(AttributesCheckUtils.getField(androidRes, "TextView_maxLines")) + && !typedArray.hasValue(AttributesCheckUtils.getField(androidRes, "TextView_maxLength"))) { + errors.add("specify maxLines or maxLength if isMultiline is true"); + } + } else { + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_lines"), false, + "remove lines and use isMultiline"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_maxLines"), false, + "maxLines remove and use isMultiline"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_minLines"), false, + "minLines remove and use isMultiline"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_maxLength"), true, + "maxLength required parameter if isMultiline is false"); + } + } + + private void initializeTextChangedListener() { + addTextChangedListener(new TextWatcher() { + + @Override + public void beforeTextChanged(final CharSequence oldText, final int start, final int count, final int after) { + //do nothing + } + + @Override + public void onTextChanged(final CharSequence inputText, final int start, final int before, final int count) { + if (onTextChangedListener != null) { + onTextChangedListener.onTextChanged(inputText); + } + } + + @Override + public void afterTextChanged(final Editable editable) { + //do nothing + } + + }); + } + + /** + * Sets if view supports multiline text alignment. + * + * @param multiline True if multiline supported. + */ + public void setMultiline(final boolean multiline) { + this.multiline = multiline; + if (multiline) { + super.setSingleLine(false); + } else { + super.setSingleLine(true); + } + } + + @Override + public void setSingleLine(final boolean singleLine) { + setMultiline(!singleLine); + } + + @Override + public void setSingleLine() { + setMultiline(false); + } + + @Override + public void setLines(final int lines) { + if (multiline && lines == 1) { + throw new IllegalStateException("lines = 1 is illegal if multiline is set to true"); + } + super.setLines(lines); + } + + @Override + public void setMaxLines(final int maxLines) { + if (!multiline && maxLines > 1) { + throw new IllegalStateException("maxLines > 1 is illegal if multiline is set to false"); + } + super.setMaxLines(maxLines); + } + + @Override + public void setMinLines(final int minLines) { + if (!multiline && minLines > 1) { + throw new IllegalStateException("minLines > 1 is illegal if multiline is set to false"); + } + super.setMinLines(minLines); + } + + @Override + public final void setIncludeFontPadding(final boolean includeFontPadding) { + throw new IllegalStateException("Do not specify font padding as it is hard to make pixel-perfect design with such option"); + } + + @Override + public void setEllipsize(final TextUtils.TruncateAt ellipsis) { + throw new IllegalStateException("Do not specify ellipsize for EditText"); + } + + /** + * Sets typeface from 'assets/fonts' folder by name. + * + * @param name Full name of typeface (without extension, e.g. 'Roboto-Regular'). + */ + public void setTypeface(@NonNull final String name) { + setTypeface(Typefaces.getByName(getContext(), name)); + } + + public void setOnTextChangedListener(@Nullable final OnTextChangedListener onTextChangedListener) { + this.onTextChangedListener = onTextChangedListener; + } + + /** + * Simplified variant of {@link TextWatcher}. + */ + public interface OnTextChangedListener { + + /** + * Calls when text have changed. + * + * @param text New text. + */ + void onTextChanged(@NonNull CharSequence text); + + } + +} \ No newline at end of file diff --git a/src/main/java/ru/touchin/roboswag/components/views/TypefacedTextView.java b/src/main/java/ru/touchin/roboswag/components/views/TypefacedTextView.java new file mode 100644 index 0000000..300ca7f --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/components/views/TypefacedTextView.java @@ -0,0 +1,400 @@ +/* + * Copyright (c) 2015 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.components.views; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.AppCompatTextView; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.TypedValue; + +import java.util.ArrayList; +import java.util.List; + +import ru.touchin.roboswag.components.R; +import ru.touchin.roboswag.components.utils.Typefaces; +import ru.touchin.roboswag.components.utils.UiUtils; +import ru.touchin.roboswag.components.views.internal.AttributesCheckUtils; +import ru.touchin.roboswag.core.log.Lc; + +/** + * Created by Gavriil Sitnikov on 18/07/2014. + * TextView that supports custom typeface and forces developer to specify {@link LineStrategy}. + * Also in debug mode it has common checks for popular bugs. + */ +@SuppressWarnings("PMD.ConstructorCallsOverridableMethod") +//ConstructorCallsOverridableMethod: it's ok as we need to setTypeface +public class TypefacedTextView extends AppCompatTextView { + + private static final int UNSPECIFIED_MEASURE_SPEC = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + private static final int START_SCALABLE_DIFFERENCE = 4; + + private static boolean inDebugMode; + + /** + * Enables debugging features like checking attributes on inflation. + */ + public static void setInDebugMode() { + inDebugMode = true; + } + + //could be null on construction + private LineStrategy lineStrategy = LineStrategy.SINGLE_LINE_ELLIPSIZE; + + public TypefacedTextView(@NonNull final Context context) { + super(context); + initialize(context, null); + } + + public TypefacedTextView(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + initialize(context, attrs); + } + + public TypefacedTextView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + initialize(context, attrs); + } + + private void initialize(@NonNull final Context context, @Nullable final AttributeSet attrs) { + super.setIncludeFontPadding(false); + if (attrs != null) { + final TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TypefacedTextView); + setLineStrategy(LineStrategy.byResIndex(typedArray.getInt(R.styleable.TypefacedTextView_lineStrategy, + LineStrategy.MULTILINE_ELLIPSIZE.ordinal()))); + if (!isInEditMode()) { + setTypeface(Typefaces.getFromAttributes(context, attrs, R.styleable.TypefacedTextView, R.styleable.TypefacedTextView_customTypeface)); + } + typedArray.recycle(); + if (inDebugMode) { + checkAttributes(context, attrs); + } + } + } + + private void checkAttributes(@NonNull final Context context, @NonNull final AttributeSet attrs) { + final List errors = new ArrayList<>(); + LineStrategy lineStrategy = null; + TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.TypefacedTextView); + AttributesCheckUtils.checkAttribute(typedArray, errors, R.styleable.TypefacedTextView_customTypeface, true, + "customTypeface required parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, R.styleable.TypefacedTextView_lineStrategy, true, + "lineStrategy required parameter"); + if (typedArray.hasValue(R.styleable.TypefacedTextView_lineStrategy)) { + lineStrategy = LineStrategy.byResIndex(typedArray.getInt(R.styleable.TypefacedTextView_lineStrategy, -1)); + } + typedArray.recycle(); + + try { + final Class androidRes = Class.forName("com.android.internal.R$styleable"); + + typedArray = context.obtainStyledAttributes(attrs, AttributesCheckUtils.getField(androidRes, "TextView")); + AttributesCheckUtils.checkRegularTextViewAttributes(typedArray, androidRes, errors, "lineStrategy"); + checkTextViewSpecificAttributes(typedArray, androidRes, errors); + + if (lineStrategy != null) { + checkLineStrategyAttributes(typedArray, androidRes, errors, lineStrategy); + } + } catch (final Exception exception) { + Lc.assertion(exception); + } + AttributesCheckUtils.handleErrors(this, errors); + typedArray.recycle(); + } + + private void checkTextViewSpecificAttributes(@NonNull final TypedArray typedArray, @NonNull final Class androidRes, + @NonNull final List errors) + throws NoSuchFieldException, IllegalAccessException { + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_phoneNumber"), false, + "phoneNumber forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_password"), false, + "password forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_numeric"), false, + "numeric forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_inputType"), false, + "inputType forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_imeOptions"), false, + "imeOptions forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_imeActionId"), false, + "imeActionId forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_imeActionLabel"), false, + "imeActionLabel forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_hint"), false, + "hint forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_editable"), false, + "editable forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_digits"), false, + "digits forbid parameter"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_cursorVisible"), false, + "cursorVisible forbid parameter"); + } + + private void checkLineStrategyAttributes(@NonNull final TypedArray typedArray, @NonNull final Class androidRes, + @NonNull final List errors, @NonNull final LineStrategy lineStrategy) + throws NoSuchFieldException, IllegalAccessException { + if (!lineStrategy.scalable) { + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_textSize"), true, + "textSize required parameter"); + } + if (lineStrategy.multiline) { + if (typedArray.getInt(AttributesCheckUtils.getField(androidRes, "TextView_lines"), -1) == 1) { + errors.add("lines should be more than 1 if lineStrategy is true"); + } + if (typedArray.getInt(AttributesCheckUtils.getField(androidRes, "TextView_maxLines"), -1) == 1) { + errors.add("maxLines should be more than 1 if lineStrategy is true"); + } + } else { + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_lines"), false, + "remove lines and use lineStrategy"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_maxLines"), false, + "remove maxLines and use lineStrategy"); + AttributesCheckUtils.checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_minLines"), false, + "remove minLines and use lineStrategy"); + } + } + + /** + * Sets behavior of text if there is no space for it in one line. + * + * @param lineStrategy Specific {@link LineStrategy}. + */ + public void setLineStrategy(@NonNull final LineStrategy lineStrategy) { + this.lineStrategy = lineStrategy; + super.setSingleLine(!lineStrategy.multiline); + if (lineStrategy.scalable) { + requestLayout(); + } + } + + /** + * Returns behavior of text if there is no space for it in one line. + * + * @return Specific {@link LineStrategy}. + */ + @NonNull + public LineStrategy getLineStrategy() { + return lineStrategy; + } + + @Override + public void setSingleLine() { + throw new IllegalStateException("Do not specify setSingleLine use setLineStrategy instead"); + } + + @Override + public void setSingleLine(final boolean singleLine) { + throw new IllegalStateException("Do not specify setSingleLine use setLineStrategy instead"); + } + + @Override + public void setLines(final int lines) { + if (lineStrategy != null && lineStrategy.multiline && lines == 1) { + throw new IllegalStateException("lines = 1 is illegal if lineStrategy is multiline"); + } + super.setLines(lines); + } + + @Override + public void setMaxLines(final int maxLines) { + if (lineStrategy != null && !lineStrategy.multiline && maxLines > 1) { + throw new IllegalStateException("maxLines > 1 is illegal if lineStrategy is single line"); + } + super.setMaxLines(maxLines); + } + + @Override + public void setMinLines(final int minLines) { + if (lineStrategy != null && !lineStrategy.multiline && minLines > 1) { + throw new IllegalStateException("minLines > 1 is illegal if lineStrategy is single line"); + } + super.setMinLines(minLines); + } + + @Override + public final void setIncludeFontPadding(final boolean includeFontPadding) { + throw new IllegalStateException("Do not specify font padding as it is hard to make pixel-perfect design with such option"); + } + + @Override + public void setEllipsize(final TextUtils.TruncateAt ellipsize) { + throw new IllegalStateException("Do not specify ellipsize use setLineStrategy instead"); + } + + /** + * Sets typeface from 'assets/fonts' folder by name. + * + * @param name Full name of typeface (without extension, e.g. 'Roboto-Regular'). + */ + public void setTypeface(@NonNull final String name) { + setTypeface(Typefaces.getByName(getContext(), name)); + } + + @Override + public void setText(final CharSequence text, final BufferType type) { + super.setText(text, type); + if (lineStrategy != null && lineStrategy.scalable) { + requestLayout(); + } + } + + @Override + public void setTextSize(final float size) { + if (lineStrategy != null && lineStrategy.scalable) { + throw new IllegalStateException("textSize call is illegal if lineStrategy is scalable"); + } + super.setTextSize(size); + } + + @Override + public void setTextSize(final int unit, final float size) { + if (lineStrategy != null && lineStrategy.scalable) { + throw new IllegalStateException("textSize call is illegal if lineStrategy is scalable"); + } + super.setTextSize(unit, size); + } + + @SuppressLint("WrongCall") + //WrongCall: actually this method is always calling from onMeasure + private void computeScalableTextSize(final int maxWidth, final int maxHeight) { + final int minDifference = (int) UiUtils.OfMetrics.dpToPixels(getContext(), 1); + int difference = (int) UiUtils.OfMetrics.dpToPixels(getContext(), START_SCALABLE_DIFFERENCE); + ScaleAction scaleAction = ScaleAction.DO_NOTHING; + ScaleAction previousScaleAction = ScaleAction.DO_NOTHING; + do { + switch (scaleAction) { + case SCALE_DOWN: + if (difference > minDifference) { + difference -= minDifference; + super.setTextSize(TypedValue.COMPLEX_UNIT_PX, Math.max(0, getTextSize() - difference)); + } else { + super.setTextSize(TypedValue.COMPLEX_UNIT_PX, Math.max(0, getTextSize() - minDifference)); + if (previousScaleAction == ScaleAction.SCALE_UP) { + return; + } + } + break; + case SCALE_UP: + if (difference > minDifference) { + difference -= minDifference; + super.setTextSize(TypedValue.COMPLEX_UNIT_PX, Math.max(0, getTextSize() + difference)); + } else { + if (previousScaleAction == ScaleAction.SCALE_DOWN) { + return; + } + super.setTextSize(TypedValue.COMPLEX_UNIT_PX, Math.max(0, getTextSize() + minDifference)); + } + break; + case DO_NOTHING: + default: + break; + } + super.onMeasure(UNSPECIFIED_MEASURE_SPEC, UNSPECIFIED_MEASURE_SPEC); + previousScaleAction = scaleAction; + scaleAction = computeScaleAction(maxWidth, maxHeight); + } while (scaleAction != ScaleAction.DO_NOTHING); + } + + @NonNull + private ScaleAction computeScaleAction(final int maxWidth, final int maxHeight) { + ScaleAction result = ScaleAction.DO_NOTHING; + if (maxWidth < getMeasuredWidth()) { + result = ScaleAction.SCALE_DOWN; + } else if (maxWidth > getMeasuredWidth()) { + result = ScaleAction.SCALE_UP; + } + + if (maxHeight < getMeasuredHeight()) { + result = ScaleAction.SCALE_DOWN; + } else if (maxHeight > getMeasuredHeight() && result != ScaleAction.SCALE_DOWN) { + result = ScaleAction.SCALE_UP; + } + return result; + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + final int maxWidth = MeasureSpec.getSize(widthMeasureSpec); + final int maxHeight = MeasureSpec.getSize(heightMeasureSpec); + if (lineStrategy == null || !lineStrategy.scalable || maxWidth <= 0 || maxHeight <= 0 || TextUtils.isEmpty(getText())) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + return; + } + + computeScalableTextSize(maxWidth, maxHeight); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + private enum ScaleAction { + SCALE_DOWN, + SCALE_UP, + DO_NOTHING + } + + /** + * Specific behavior, mostly based on combination of {@link #getEllipsize()} and {@link #getMaxLines()} to specify how view should show text + * if there is no space for it on one line. + */ + public enum LineStrategy { + + /** + * Not more than one line and ellipsize text with dots at end. + */ + SINGLE_LINE_ELLIPSIZE(false, false), + /** + * Not more than one line and ellipsize text with marquee at end. + */ + SINGLE_LINE_MARQUEE(false, false), + /** + * Not more than one line and scale text to maximum possible size. + */ + SINGLE_LINE_AUTO_SCALE(false, true), + /** + * More than one line and ellipsize text with dots at end. + */ + MULTILINE_ELLIPSIZE(true, false), + /** + * More than one line and ellipsize text with marquee at end. + */ + MULTILINE_MARQUEE(true, false); + + @NonNull + public static LineStrategy byResIndex(final int resIndex) { + if (resIndex < 0 || resIndex >= values().length) { + Lc.assertion("Unexpected resIndex " + resIndex); + return MULTILINE_ELLIPSIZE; + } + return values()[resIndex]; + } + + private final boolean multiline; + private final boolean scalable; + + LineStrategy(final boolean multiline, final boolean scalable) { + this.multiline = multiline; + this.scalable = scalable; + } + + } + +} diff --git a/src/main/java/ru/touchin/roboswag/components/views/internal/AttributesCheckUtils.java b/src/main/java/ru/touchin/roboswag/components/views/internal/AttributesCheckUtils.java new file mode 100644 index 0000000..b3f82f3 --- /dev/null +++ b/src/main/java/ru/touchin/roboswag/components/views/internal/AttributesCheckUtils.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2015 RoboSwag (Gavriil Sitnikov, Vsevolod Ivanov) + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package ru.touchin.roboswag.components.views.internal; + +import android.content.res.TypedArray; +import android.support.annotation.NonNull; +import android.support.annotation.StyleableRes; +import android.text.TextUtils; +import android.view.View; + +import java.lang.reflect.Field; +import java.util.Collection; + +import ru.touchin.roboswag.components.utils.UiUtils; +import ru.touchin.roboswag.core.log.Lc; + +/** + * Created by Gavriil Sitnikov on 13/06/2016. + * Bunch of inner helper library methods to validate attributes of custom views. + */ +public final class AttributesCheckUtils { + + /** + * Gets static field of class. + * + * @param resourcesClass Class to get field from; + * @param fieldName name of field; + * @param Type of object that is stored in field; + * @return Field value; + * @throws NoSuchFieldException Throws on reflection call; + * @throws IllegalAccessException Throws on reflection call. + */ + @SuppressWarnings("unchecked") + public static T getField(@NonNull final Class resourcesClass, @NonNull final String fieldName) + throws NoSuchFieldException, IllegalAccessException { + final Field field = resourcesClass.getDeclaredField(fieldName); + field.setAccessible(true); + return (T) field.get(null); + } + + /** + * Checks if attribute is in array or not and collecterror if attribute missed. + * + * @param typedArray Array of attributes; + * @param errors Errors to collect into; + * @param resourceId Id of attribute; + * @param required Is parameter have to be in array OR it have not to be in; + * @param description Description of error. + */ + public static void checkAttribute(@NonNull final TypedArray typedArray, + @NonNull final Collection errors, + @StyleableRes final int resourceId, + final boolean required, + @NonNull final String description) { + if ((required && typedArray.hasValue(resourceId)) + || (!required && !typedArray.hasValue(resourceId))) { + return; + } + errors.add(description); + } + + /** + * Collects regular {@link android.widget.TextView} errors. + * + * @param typedArray Array of attributes; + * @param androidRes Class of styleable attributes; + * @param errors Errors to collect into; + * @param lineStrategyParameterName name of line strategy parameter; + * @throws NoSuchFieldException Throws during getting attribute values through reflection; + * @throws IllegalAccessException Throws during getting attribute values through reflection. + */ + public static void checkRegularTextViewAttributes(@NonNull final TypedArray typedArray, @NonNull final Class androidRes, + @NonNull final Collection errors, @NonNull final String lineStrategyParameterName) + throws NoSuchFieldException, IllegalAccessException { + checkAttribute(typedArray, errors, getField(androidRes, "TextView_typeface"), false, "remove typeface and use customTypeface"); + checkAttribute(typedArray, errors, getField(androidRes, "TextView_textStyle"), false, "remove textStyle and use customTypeface"); + checkAttribute(typedArray, errors, getField(androidRes, "TextView_fontFamily"), false, "remove fontFamily and use customTypeface"); + checkAttribute(typedArray, errors, getField(androidRes, "TextView_includeFontPadding"), false, "includeFontPadding forbid parameter"); + checkAttribute(typedArray, errors, getField(androidRes, "TextView_singleLine"), false, + "remove singleLine and use " + lineStrategyParameterName); + checkAttribute(typedArray, errors, getField(androidRes, "TextView_ellipsize"), false, + "remove ellipsize and use " + lineStrategyParameterName); + checkAttribute(typedArray, errors, AttributesCheckUtils.getField(androidRes, "TextView_textColor"), true, "textColor required parameter"); + } + + /** + * Inner helper library method to merge errors in string and assert it. + * + * @param view View with errors; + * @param errors Errors of view. + */ + public static void handleErrors(@NonNull final View view, @NonNull final Collection errors) { + if (!errors.isEmpty()) { + Lc.assertion("Errors for view [" + UiUtils.OfViews.getViewIdString(view) + "]:\n" + TextUtils.join("\n", errors)); + } + } + + private AttributesCheckUtils() { + } + +} diff --git a/src/main/res/values/attrs.xml b/src/main/res/values/attrs.xml new file mode 100644 index 0000000..9218041 --- /dev/null +++ b/src/main/res/values/attrs.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file