diff --git a/base/src/main/java/com/heyongrui/base/widget/numberruntextview/BigDecimalEvaluator.java b/base/src/main/java/com/heyongrui/base/widget/numberruntextview/BigDecimalEvaluator.java new file mode 100644 index 0000000..b58288b --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/numberruntextview/BigDecimalEvaluator.java @@ -0,0 +1,19 @@ +package com.heyongrui.base.widget.numberruntextview; + +import android.animation.TypeEvaluator; + +import java.math.BigDecimal; + +/** + * Created by lambert on 2018/10/11. + */ + +public class BigDecimalEvaluator implements TypeEvaluator { + @Override + public Object evaluate(float fraction, Object startValue, Object endValue) { + BigDecimal start = (BigDecimal) startValue; + BigDecimal end = (BigDecimal) endValue; + BigDecimal result = end.subtract(start); + return result.multiply(new BigDecimal("" + fraction)).add(start); + } +} \ No newline at end of file diff --git a/base/src/main/java/com/heyongrui/base/widget/numberruntextview/EditTextWithClear.java b/base/src/main/java/com/heyongrui/base/widget/numberruntextview/EditTextWithClear.java new file mode 100644 index 0000000..301b944 --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/numberruntextview/EditTextWithClear.java @@ -0,0 +1,101 @@ +package com.heyongrui.base.widget.numberruntextview; + +import android.text.Editable; +import android.text.Selection; +import android.text.TextWatcher; +import android.widget.EditText; + +/** + * Created by lambert on 2018/10/11. + * 银行卡号输入框格式(每4位有个空格) + */ + +public class EditTextWithClear implements TextWatcher { + + private EditText mEditText; + private char mDivider; + + int beforeTextLength = 0; + int onTextLength = 0; + boolean isChanged = false; + + int location = 0;// 记录光标的位置 + private char[] tempChar; + private StringBuffer buffer = new StringBuffer(); + int konggeNumberB = 0; + + public EditTextWithClear(EditText editText, char divider) { + mEditText = editText; + mDivider = divider; + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, + int count) { + onTextLength = s.length(); + buffer.append(s.toString()); + if (onTextLength == beforeTextLength || onTextLength <= 3 + || isChanged) { + isChanged = false; + return; + } + isChanged = true; + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, + int after) { + beforeTextLength = s.length(); + if (buffer.length() > 0) { + buffer.delete(0, buffer.length()); + } + konggeNumberB = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == mDivider) { + konggeNumberB++; + } + } + } + + @Override + public void afterTextChanged(Editable s) { + if (isChanged) { + location = mEditText.getSelectionEnd(); + int index = 0; + while (index < buffer.length()) { + if (buffer.charAt(index) == mDivider) { + buffer.deleteCharAt(index); + } else { + index++; + } + } + + index = 0; + int spaceNumberCount = 0;//空格的个数 + while (index < buffer.length()) { + if ((index == 4 || index == 9 || index == 14 || index == 19 || index == 24 || index == 29)) { + buffer.insert(index, mDivider); + spaceNumberCount++; + } + index++; + } + + if (spaceNumberCount > konggeNumberB) { + location += (spaceNumberCount - konggeNumberB); + } + + tempChar = new char[buffer.length()]; + buffer.getChars(0, buffer.length(), tempChar, 0); + String str = buffer.toString(); + if (location > str.length()) { + location = str.length(); + } else if (location < 0) { + location = 0; + } + mEditText.setText(str); + Editable etable = mEditText.getText(); + Selection.setSelection(etable, location); + isChanged = false; + } + } +} \ No newline at end of file diff --git a/base/src/main/java/com/heyongrui/base/widget/numberruntextview/NumberRunningTextView.java b/base/src/main/java/com/heyongrui/base/widget/numberruntextview/NumberRunningTextView.java new file mode 100644 index 0000000..2bb0ea1 --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/numberruntextview/NumberRunningTextView.java @@ -0,0 +1,198 @@ +package com.heyongrui.base.widget.numberruntextview; + +import android.animation.Animator; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.widget.TextView; + +import com.heyongrui.base.R; + +import java.math.BigDecimal; +import java.text.DecimalFormat; + + +/** + * Created by lambert on 2018/10/11. + */ + +@SuppressLint("AppCompatCustomView") +public class NumberRunningTextView extends TextView { + + private static final int MONEY_TYPE = 0; + private static final int NUM_TYPE = 1; + + private int textType;//内容的类型,默认是金钱类型 + private boolean useCommaFormat;//是否使用每三位数字一个逗号的格式,让数字显得比较好看,默认使用 + private boolean runWhenChange;//是否当内容有改变才使用动画,默认是 + private int duration;//动画的周期,默认为800ms + private int minNum;//显示数字最少要达到这个数字才滚动 默认为1 + private float minMoney;//显示金额最少要达到这个数字才滚动 默认为0.3 + private Animator.AnimatorListener animatorListener; + + private DecimalFormat formatter = new DecimalFormat("0.00");// 格式化金额,保留两位小数 + private String preStr; + + + public NumberRunningTextView(Context context) { + this(context, null); + } + + public NumberRunningTextView(Context context, AttributeSet attrs) { + this(context, attrs, android.R.attr.textViewStyle); + } + + public NumberRunningTextView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.NumberRunningTextView); + duration = ta.getInt(R.styleable.NumberRunningTextView_duration, 1000); + textType = ta.getInt(R.styleable.NumberRunningTextView_textType, MONEY_TYPE); + useCommaFormat = ta.getBoolean(R.styleable.NumberRunningTextView_useCommaFormat, true); + runWhenChange = ta.getBoolean(R.styleable.NumberRunningTextView_runWhenChange, true); + minNum = ta.getInt(R.styleable.NumberRunningTextView_minNum, 3); + minMoney = ta.getFloat(R.styleable.NumberRunningTextView_minMoney, 0.1f); + + ta.recycle(); + } + + + /** + * 设置需要滚动的金钱(必须为正数)或整数(必须为正数)的字符串 + * + * @param str + */ + public void setContent(String str) { + //如果是当内容改变的时候才执行滚动动画,判断内容是否有变化 + if (runWhenChange) { + if (TextUtils.isEmpty(preStr)) { + //如果上一次的str为空 + preStr = str; + useAnimByType(str); + return; + } + + //如果上一次的str不为空,判断两次内容是否一致 + if (preStr.equals(str)) { + //如果两次内容一致,则不做处理 + return; + } + + preStr = str;//如果两次内容不一致,记录最新的str + } + + useAnimByType(str); + } + + private void useAnimByType(String str) { + if (textType == MONEY_TYPE) { + playMoneyAnim(str); + } else if (textType == NUM_TYPE) { + playNumAnim(str); + } + } + + public void setAnimatorListener(Animator.AnimatorListener animatorListener) { + this.animatorListener = animatorListener; + } + + /** + * 播放金钱数字动画的方法 + * + * @param moneyStr + */ + public void playMoneyAnim(String moneyStr) { + String money = moneyStr.replace(",", "").replace("-", "");//如果传入的数字已经是使用逗号格式化过的,或者含有符号,去除逗号和负号 + try { + BigDecimal bigDecimal = new BigDecimal(money); + float finalFloat = bigDecimal.floatValue(); + if (finalFloat < minMoney) { + //如果传入的为0,则直接使用setText() + setText(moneyStr); + return; + } + ValueAnimator floatAnimator = ValueAnimator.ofObject(new BigDecimalEvaluator(), new BigDecimal(0), bigDecimal); + if (animatorListener != null) { + floatAnimator.addListener(animatorListener); + } + floatAnimator.setDuration(duration); + floatAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + BigDecimal currentNum = (BigDecimal) animation.getAnimatedValue(); + String str = formatter.format(Double.parseDouble(currentNum.toString()));//格式化成两位小数 + // 更新显示的内容 + if (useCommaFormat) { + //使用每三位数字一个逗号的格式 + String formatStr = addComma(str);//三位一个逗号格式的字符串 + setText(formatStr); + } else { + setText(str); + } + } + }); + floatAnimator.start(); + } catch (NumberFormatException e) { + e.printStackTrace(); + this.setText(moneyStr);//如果转换Double失败则直接用setText + } + } + + public String addComma(String str) { + str = new StringBuilder(str).reverse().toString(); //先将字符串颠倒顺序 + if (str.equals("0")) { + return str; + } + String str2 = ""; + for (int i = 0; i < str.length(); i++) { + if (i * 3 + 3 > str.length()) { + str2 += str.substring(i * 3, str.length()); + break; + } + str2 += str.substring(i * 3, i * 3 + 3) + ","; + } + if (str2.endsWith(",")) { + str2 = str2.substring(0, str2.length() - 1); + } + //最后再将顺序反转过来 + String temp = new StringBuilder(str2).reverse().toString(); + //将最后的,去掉 + return temp.substring(0, temp.lastIndexOf(",")) + temp.substring(temp.lastIndexOf(",") + 1, temp.length()); + } + + /** + * 播放数字动画的方法 + * + * @param numStr + */ + public void playNumAnim(String numStr) { + String num = numStr.replace(",", "").replace("-", "");//如果传入的数字已经是使用逗号格式化过的,或者含有符号,去除逗号和负号 + try { + int finalNum = Integer.parseInt(num); + if (finalNum < minNum) { + //由于是整数,每次是递增1,所以如果传入的数字比帧数小,则直接使用setText() + this.setText(numStr); + return; + } + ValueAnimator intAnimator = new ValueAnimator().ofInt(0, finalNum); + if (animatorListener != null) { + intAnimator.addListener(animatorListener); + } + intAnimator.setDuration(duration); + intAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + int currentNum = (int) animation.getAnimatedValue(); + setText(String.valueOf(currentNum)); + } + }); + intAnimator.start(); + } catch (NumberFormatException e) { + e.printStackTrace(); + setText(numStr);//如果转换Double失败则直接用setText + } + } +} diff --git a/base/src/main/java/com/heyongrui/base/widget/tickerview/LevenshteinUtils.java b/base/src/main/java/com/heyongrui/base/widget/tickerview/LevenshteinUtils.java new file mode 100644 index 0000000..5cba4e0 --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/tickerview/LevenshteinUtils.java @@ -0,0 +1,212 @@ +package com.heyongrui.base.widget.tickerview; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Created by lambert on 2018/10/11. + */ + +public class LevenshteinUtils { + static final int ACTION_SAME = 0; + static final int ACTION_INSERT = 1; + static final int ACTION_DELETE = 2; + + /** + * This is a wrapper function around {@link #appendColumnActionsForSegment} that + * additionally takes in supportedCharacters. It uses supportedCharacters to compute whether + * the current character should be animated or if it should remain in-place. + *

+ * For specific implementation details, see {@link #appendColumnActionsForSegment}. + * + * @param source the source char array to animate from + * @param target the target char array to animate to + * @param supportedCharacters all characters that support custom animation. + * @return an int array of size min(source.length, target.length) where each index + * corresponds to one of {@link #ACTION_SAME}, {@link #ACTION_INSERT}, + * {@link #ACTION_DELETE} to represent if we update, insert, or delete a character + * at the particular index. + */ + public static int[] computeColumnActions(char[] source, char[] target, + Set supportedCharacters) { + int sourceIndex = 0; + int targetIndex = 0; + + List columnActions = new ArrayList<>(); + while (true) { + // Check for terminating conditions + final boolean reachedEndOfSource = sourceIndex == source.length; + final boolean reachedEndOfTarget = targetIndex == target.length; + if (reachedEndOfSource && reachedEndOfTarget) { + break; + } else if (reachedEndOfSource) { + fillWithActions(columnActions, target.length - targetIndex, ACTION_INSERT); + break; + } else if (reachedEndOfTarget) { + fillWithActions(columnActions, source.length - sourceIndex, ACTION_DELETE); + break; + } + + final boolean containsSourceChar = supportedCharacters.contains(source[sourceIndex]); + final boolean containsTargetChar = supportedCharacters.contains(target[targetIndex]); + + if (containsSourceChar && containsTargetChar) { + // We reached a segment that we can perform animations on + final int sourceEndIndex = + findNextUnsupportedChar(source, sourceIndex + 1, supportedCharacters); + final int targetEndIndex = + findNextUnsupportedChar(target, targetIndex + 1, supportedCharacters); + + appendColumnActionsForSegment( + columnActions, + source, + target, + sourceIndex, + sourceEndIndex, + targetIndex, + targetEndIndex + ); + sourceIndex = sourceEndIndex; + targetIndex = targetEndIndex; + } else if (containsSourceChar) { + // We are animating in a target character that isn't supported + columnActions.add(ACTION_INSERT); + targetIndex++; + } else if (containsTargetChar) { + // We are animating out a source character that isn't supported + columnActions.add(ACTION_DELETE); + sourceIndex++; + } else { + // Both characters are not supported, perform default animation to replace + columnActions.add(ACTION_SAME); + sourceIndex++; + targetIndex++; + } + } + + // Concat all of the actions into one array + final int[] result = new int[columnActions.size()]; + for (int i = 0; i < columnActions.size(); i++) { + result[i] = columnActions.get(i); + } + return result; + } + + private static int findNextUnsupportedChar(char[] chars, int startIndex, + Set supportedCharacters) { + for (int i = startIndex; i < chars.length; i++) { + if (!supportedCharacters.contains(chars[i])) { + return i; + } + } + return chars.length; + } + + private static void fillWithActions(List actions, int num, int action) { + for (int i = 0; i < num; i++) { + actions.add(action); + } + } + + /** + * Run a slightly modified version of Levenshtein distance algorithm to compute the minimum + * edit distance between the current and the target text within the start and end bounds. + * Unlike the traditional algorithm, we force return all {@link #ACTION_SAME} for inputs that + * are the same length (so optimize update over insertion/deletion). + * + * @param columnActions the target list to append actions into + * @param source the source character array + * @param target the target character array + * @param sourceStart the start index of source to compute column actions (inclusive) + * @param sourceEnd the end index of source to compute column actions (exclusive) + * @param targetStart the start index of target to compute column actions (inclusive) + * @param targetEnd the end index of target to compute column actions (exclusive) + */ + private static void appendColumnActionsForSegment( + List columnActions, + char[] source, + char[] target, + int sourceStart, + int sourceEnd, + int targetStart, + int targetEnd + ) { + final int sourceLength = sourceEnd - sourceStart; + final int targetLength = targetEnd - targetStart; + final int resultLength = Math.max(sourceLength, targetLength); + + if (sourceLength == targetLength) { + // No modifications needed if the length of the strings are the same + fillWithActions(columnActions, resultLength, ACTION_SAME); + return; + } + + final int numRows = sourceLength + 1; + final int numCols = targetLength + 1; + + // Compute the Levenshtein matrix + final int[][] matrix = new int[numRows][numCols]; + + for (int i = 0; i < numRows; i++) { + matrix[i][0] = i; + } + for (int j = 0; j < numCols; j++) { + matrix[0][j] = j; + } + + int cost; + for (int row = 1; row < numRows; row++) { + for (int col = 1; col < numCols; col++) { + cost = source[row - 1 + sourceStart] == target[col - 1 + targetStart] ? 0 : 1; + + matrix[row][col] = min( + matrix[row - 1][col] + 1, + matrix[row][col - 1] + 1, + matrix[row - 1][col - 1] + cost); + } + } + + // Reverse trace the matrix to compute the necessary actions + final List resultList = new ArrayList<>(resultLength * 2); + int row = numRows - 1; + int col = numCols - 1; + while (row > 0 || col > 0) { + if (row == 0) { + // At the top row, can only move left, meaning insert column + resultList.add(ACTION_INSERT); + col--; + } else if (col == 0) { + // At the left column, can only move up, meaning delete column + resultList.add(ACTION_DELETE); + row--; + } else { + final int insert = matrix[row][col - 1]; + final int delete = matrix[row - 1][col]; + final int replace = matrix[row - 1][col - 1]; + + if (insert < delete && insert < replace) { + resultList.add(ACTION_INSERT); + col--; + } else if (delete < replace) { + resultList.add(ACTION_DELETE); + row--; + } else { + resultList.add(ACTION_SAME); + row--; + col--; + } + } + } + + // Reverse the actions to get the correct ordering + final int resultSize = resultList.size(); + for (int i = resultSize - 1; i >= 0; i--) { + columnActions.add(resultList.get(i)); + } + } + + private static int min(int first, int second, int third) { + return Math.min(first, Math.min(second, third)); + } +} diff --git a/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerCharacterList.java b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerCharacterList.java new file mode 100644 index 0000000..8e44c61 --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerCharacterList.java @@ -0,0 +1,92 @@ +package com.heyongrui.base.widget.tickerview; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * Created by lambert on 2018/10/11. + */ + +public class TickerCharacterList { + private final int numOriginalCharacters; + // The saved character list will always be of the format: EMPTY, list, list + private final char[] characterList; + // A minor optimization so that we can cache the indices of each character. + private final Map characterIndicesMap; + + TickerCharacterList(String characterList) { + if (characterList.contains(Character.toString(TickerUtils.EMPTY_CHAR))) { + throw new IllegalArgumentException( + "You cannot include TickerUtils.EMPTY_CHAR in the character list."); + } + + final char[] charsArray = characterList.toCharArray(); + final int length = charsArray.length; + this.numOriginalCharacters = length; + + characterIndicesMap = new HashMap<>(length); + for (int i = 0; i < length; i++) { + characterIndicesMap.put(charsArray[i], i); + } + + this.characterList = new char[length * 2 + 1]; + this.characterList[0] = TickerUtils.EMPTY_CHAR; + for (int i = 0; i < length; i++) { + this.characterList[1 + i] = charsArray[i]; + this.characterList[1 + length + i] = charsArray[i]; + } + } + + /** + * @param start the character that we want to animate from + * @param end the character that we want to animate to + * @return a valid pair of start and end indices, or null if the inputs are not supported. + */ + CharacterIndices getCharacterIndices(char start, char end) { + int startIndex = getIndexOfChar(start); + int endIndex = getIndexOfChar(end); + if (startIndex < 0 || endIndex < 0) { + return null; + } + + // see if the wrap-around animation is shorter distance than the original animation + if (start != TickerUtils.EMPTY_CHAR && end != TickerUtils.EMPTY_CHAR && + endIndex < startIndex) { + final int nonWrapDistance = startIndex - endIndex; + final int wrapDistance = numOriginalCharacters - startIndex + endIndex; + if (wrapDistance < nonWrapDistance) { + endIndex = endIndex + numOriginalCharacters; + } + } + return new CharacterIndices(startIndex, endIndex); + } + + Set getSupportedCharacters() { + return characterIndicesMap.keySet(); + } + + char[] getCharacterList() { + return characterList; + } + + private int getIndexOfChar(char c) { + if (c == TickerUtils.EMPTY_CHAR) { + return 0; + } else if (characterIndicesMap.containsKey(c)) { + return characterIndicesMap.get(c) + 1; + } else { + return -1; + } + } + + class CharacterIndices { + final int startIndex; + final int endIndex; + + public CharacterIndices(int startIndex, int endIndex) { + this.startIndex = startIndex; + this.endIndex = endIndex; + } + } +} diff --git a/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerColumn.java b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerColumn.java new file mode 100644 index 0000000..e7663fc --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerColumn.java @@ -0,0 +1,222 @@ +package com.heyongrui.base.widget.tickerview; + +import android.graphics.Canvas; +import android.graphics.Paint; + +/** + * Created by lambert on 2018/10/11. + */ + +public class TickerColumn { + private final TickerCharacterList[] characterLists; + private final TickerDrawMetrics metrics; + + private char currentChar = TickerUtils.EMPTY_CHAR; + private char targetChar = TickerUtils.EMPTY_CHAR; + + // The indices characters simply signify what positions are for the current and target + // characters in the assigned characterList. This tells us how to animate from the current + // to the target characters. + private char[] currentCharacterList; + private int startIndex; + private int endIndex; + + // Drawing state variables that get updated whenever animation progress gets updated. + private int bottomCharIndex; + private float bottomDelta; + private float charHeight; + + // Drawing state variables for handling size transition + private float sourceWidth, currentWidth, targetWidth, minimumRequiredWidth; + + // The bottom delta variables signifies the vertical offset that the bottom drawn character + // is seeing. If the delta is 0, it means that the character is perfectly centered. If the + // delta is negative, it means that the bottom character is poking out from the bottom and + // part of the top character is visible. The delta should never be positive because it means + // that the bottom character is not actually the bottom character. + private float currentBottomDelta; + private float previousBottomDelta; + private int directionAdjustment; + + TickerColumn(TickerCharacterList[] characterLists, TickerDrawMetrics metrics) { + this.characterLists = characterLists; + this.metrics = metrics; + } + + /** + * Tells the column that the next character it should show is {@param targetChar}. This can + * change can either be animated or instant depending on the animation progress set by + * {@link #setAnimationProgress(float)}. + */ + void setTargetChar(char targetChar) { + // Set the current and target characters for the animation + this.targetChar = targetChar; + this.sourceWidth = this.currentWidth; + this.targetWidth = metrics.getCharWidth(targetChar); + this.minimumRequiredWidth = Math.max(this.sourceWidth, this.targetWidth); + + // Calculate the current indices + setCharacterIndices(); + + final boolean scrollDown = endIndex >= startIndex; + directionAdjustment = scrollDown ? 1 : -1; + + // Save the currentBottomDelta as previousBottomDelta in case this call to setTargetChar + // interrupted a previously running animation. The deltas will then be used to compute + // offset so that the interruption feels smooth on the UI. + previousBottomDelta = currentBottomDelta; + currentBottomDelta = 0f; + } + + char getCurrentChar() { + return currentChar; + } + + char getTargetChar() { + return targetChar; + } + + float getCurrentWidth() { + checkForDrawMetricsChanges(); + return currentWidth; + } + + float getMinimumRequiredWidth() { + checkForDrawMetricsChanges(); + return minimumRequiredWidth; + } + + /** + * A helper method for populating {@link #startIndex} and {@link #endIndex} given the + * current and target characters for the animation. + */ + private void setCharacterIndices() { + currentCharacterList = null; + + for (int i = 0; i < characterLists.length; i++) { + final TickerCharacterList.CharacterIndices indices = + characterLists[i].getCharacterIndices(currentChar, targetChar); + if (indices != null) { + this.currentCharacterList = this.characterLists[i].getCharacterList(); + this.startIndex = indices.startIndex; + this.endIndex = indices.endIndex; + } + } + + // If we didn't find a list that contains both characters, just perform a default animation + // going straight from source to target + if (currentCharacterList == null) { + if (currentChar == targetChar) { + currentCharacterList = new char[] {currentChar}; + startIndex = endIndex = 0; + } else { + currentCharacterList = new char[] {currentChar, targetChar}; + startIndex = 0; + endIndex = 1; + } + } + } + + void onAnimationEnd() { + checkForDrawMetricsChanges(); + minimumRequiredWidth = currentWidth; + } + + private void checkForDrawMetricsChanges() { + final float currentTargetWidth = metrics.getCharWidth(targetChar); + // Only resize due to DrawMetrics changes when we are done with whatever animation we + // are running. + if (currentWidth == targetWidth && targetWidth != currentTargetWidth) { + this.minimumRequiredWidth = this.currentWidth = this.targetWidth = currentTargetWidth; + } + } + + void setAnimationProgress(float animationProgress) { + if (animationProgress == 1f) { + // Animation finished (or never started), set to stable state. + this.currentChar = this.targetChar; + currentBottomDelta = 0f; + previousBottomDelta = 0f; + } + + final float charHeight = metrics.getCharHeight(); + + // First let's find the total height of this column between the start and end chars. + final float totalHeight = charHeight * Math.abs(endIndex - startIndex); + + // The current base is then the part of the total height that we have progressed to + // from the animation. For example, there might be 5 characters, each character is + // 2px tall, so the totalHeight is 10. If we are at 50% progress, then our baseline + // in this column is at 5 out of 10 (which is the 3rd character with a -50% offset + // to the baseline). + final float currentBase = animationProgress * totalHeight; + + // Given the current base, we now can find which character should drawn on the bottom. + // Note that this position is a float. For example, if the bottomCharPosition is + // 4.5, it means that the bottom character is the 4th character, and it has a -50% + // offset relative to the baseline. + final float bottomCharPosition = currentBase / charHeight; + + // By subtracting away the integer part of bottomCharPosition, we now have the + // percentage representation of the bottom char's offset. + final float bottomCharOffsetPercentage = bottomCharPosition - (int) bottomCharPosition; + + // We might have interrupted a previous animation if previousBottomDelta is not 0f. + // If that's the case, we need to take this delta into account so that the previous + // character offset won't be wiped away when we start a new animation. + // We multiply by the inverse percentage so that the offset contribution from the delta + // progresses along with the rest of the animation (from full delta to 0). + final float additionalDelta = previousBottomDelta * (1f - animationProgress); + + // Now, using the bottom char's offset percentage and the delta we have from the + // previous animation, we can now compute what's the actual offset of the bottom + // character in the column relative to the baseline. + bottomDelta = bottomCharOffsetPercentage * charHeight * directionAdjustment + + additionalDelta; + + // Figure out what the actual character index is in the characterList, and then + // draw the character with the computed offset. + bottomCharIndex = startIndex + ((int) bottomCharPosition * directionAdjustment); + + this.charHeight = charHeight; + this.currentWidth = sourceWidth + (targetWidth - sourceWidth) * animationProgress; + } + + /** + * Draw the current state of the column as it's animating from one character in the list + * to another. This method will take into account various factors such as animation + * progress and the previously interrupted animation state to render the characters + * in the correct position on the canvas. + */ + void draw(Canvas canvas, Paint textPaint) { + if (drawText(canvas, textPaint, currentCharacterList, bottomCharIndex, bottomDelta)) { + // Save the current drawing state in case our animation gets interrupted + if (bottomCharIndex >= 0) { + currentChar = currentCharacterList[bottomCharIndex]; + } + currentBottomDelta = bottomDelta; + } + + // Draw the corresponding top and bottom characters if applicable + drawText(canvas, textPaint, currentCharacterList, bottomCharIndex + 1, + bottomDelta - charHeight); + // Drawing the bottom character here might seem counter-intuitive because we've been + // computing for the bottom character this entire time. But the bottom character + // computed above might actually be above the baseline if we interrupted a previous + // animation that gave us a positive additionalDelta. + drawText(canvas, textPaint, currentCharacterList, bottomCharIndex - 1, + bottomDelta + charHeight); + } + + /** + * @return whether the text was successfully drawn on the canvas + */ + private boolean drawText(Canvas canvas, Paint textPaint, char[] characterList, + int index, float verticalOffset) { + if (index >= 0 && index < characterList.length) { + canvas.drawText(characterList, index, 1, 0f, verticalOffset, textPaint); + return true; + } + return false; + } +} diff --git a/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerColumnManager.java b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerColumnManager.java new file mode 100644 index 0000000..7c92153 --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerColumnManager.java @@ -0,0 +1,140 @@ +package com.heyongrui.base.widget.tickerview; + +import android.graphics.Canvas; +import android.graphics.Paint; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +/** + * Created by lambert on 2018/10/11. + */ +@SuppressWarnings("ForLoopReplaceableByForEach") +class TickerColumnManager { + final ArrayList tickerColumns = new ArrayList<>(); + private final TickerDrawMetrics metrics; + + private TickerCharacterList[] characterLists; + private Set supportedCharacters; + + TickerColumnManager(TickerDrawMetrics metrics) { + this.metrics = metrics; + } + + /** + * @inheritDoc TickerView#setCharacterLists + */ + void setCharacterLists(String... characterLists) { + this.characterLists = new TickerCharacterList[characterLists.length]; + for (int i = 0; i < characterLists.length; i++) { + this.characterLists[i] = new TickerCharacterList(characterLists[i]); + } + + this.supportedCharacters = new HashSet<>(); + for (int i = 0; i < characterLists.length; i++) { + this.supportedCharacters.addAll(this.characterLists[i].getSupportedCharacters()); + } + } + + TickerCharacterList[] getCharacterLists() { + return characterLists; + } + + /** + * Tell the column manager the new target text that it should display. + */ + void setText(char[] text) { + if (characterLists == null) { + throw new IllegalStateException("Need to call #setCharacterLists first."); + } + + // First remove any zero-width columns + for (int i = 0; i < tickerColumns.size(); ) { + final TickerColumn tickerColumn = tickerColumns.get(i); + if (tickerColumn.getCurrentWidth() > 0) { + i++; + } else { + tickerColumns.remove(i); + } + } + + // Use Levenshtein distance algorithm to figure out how to manipulate the columns + final int[] actions = LevenshteinUtils.computeColumnActions( + getCurrentText(), text, supportedCharacters + ); + int columnIndex = 0; + int textIndex = 0; + for (int i = 0; i < actions.length; i++) { + switch (actions[i]) { + case LevenshteinUtils.ACTION_INSERT: + tickerColumns.add(columnIndex, + new TickerColumn(characterLists, metrics)); + // Intentional fallthrough + case LevenshteinUtils.ACTION_SAME: + tickerColumns.get(columnIndex).setTargetChar(text[textIndex]); + columnIndex++; + textIndex++; + break; + case LevenshteinUtils.ACTION_DELETE: + tickerColumns.get(columnIndex).setTargetChar(TickerUtils.EMPTY_CHAR); + columnIndex++; + break; + default: + throw new IllegalArgumentException("Unknown action: " + actions[i]); + } + } + } + + void onAnimationEnd() { + for (int i = 0, size = tickerColumns.size(); i < size; i++) { + final TickerColumn column = tickerColumns.get(i); + column.onAnimationEnd(); + } + } + + void setAnimationProgress(float animationProgress) { + for (int i = 0, size = tickerColumns.size(); i < size; i++) { + final TickerColumn column = tickerColumns.get(i); + column.setAnimationProgress(animationProgress); + } + } + + float getMinimumRequiredWidth() { + float width = 0f; + for (int i = 0, size = tickerColumns.size(); i < size; i++) { + width += tickerColumns.get(i).getMinimumRequiredWidth(); + } + return width; + } + + float getCurrentWidth() { + float width = 0f; + for (int i = 0, size = tickerColumns.size(); i < size; i++) { + width += tickerColumns.get(i).getCurrentWidth(); + } + return width; + } + + char[] getCurrentText() { + final int size = tickerColumns.size(); + final char[] currentText = new char[size]; + for (int i = 0; i < size; i++) { + currentText[i] = tickerColumns.get(i).getCurrentChar(); + } + return currentText; + } + + /** + * This method will draw onto the canvas the appropriate UI state of each column dictated + * by {@param animationProgress}. As a side effect, this method will also translate the canvas + * accordingly for the draw procedures. + */ + void draw(Canvas canvas, Paint textPaint) { + for (int i = 0, size = tickerColumns.size(); i < size; i++) { + final TickerColumn column = tickerColumns.get(i); + column.draw(canvas, textPaint); + canvas.translate(column.getCurrentWidth(), 0f); + } + } +} diff --git a/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerDrawMetrics.java b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerDrawMetrics.java new file mode 100644 index 0000000..909d2de --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerDrawMetrics.java @@ -0,0 +1,55 @@ +package com.heyongrui.base.widget.tickerview; + +import android.graphics.Paint; + +import java.util.HashMap; +import java.util.Map; + +/** + * Created by lambert on 2018/10/11. + */ + +public class TickerDrawMetrics { + private final Paint textPaint; + + // These are attributes on the text paint used for measuring and drawing the text on the + // canvas. These attributes are reset whenever anything on the text paint changes. + private final Map charWidths = new HashMap<>(256); + private float charHeight, charBaseline; + + TickerDrawMetrics(Paint textPaint) { + this.textPaint = textPaint; + invalidate(); + } + + void invalidate() { + charWidths.clear(); + final Paint.FontMetrics fm = textPaint.getFontMetrics(); + charHeight = fm.bottom - fm.top; + charBaseline = -fm.top; + } + + float getCharWidth(char character) { + if (character == TickerUtils.EMPTY_CHAR) { + return 0; + } + + // This method will lazily initialize the char width map. + final Float value = charWidths.get(character); + if (value != null) { + return value; + } else { + final float width = textPaint.measureText(Character.toString(character)); + charWidths.put(character, width); + return width; + } + } + + float getCharHeight() { + return charHeight; + } + + float getCharBaseline() { + return charBaseline; + } +} diff --git a/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerUtils.java b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerUtils.java new file mode 100644 index 0000000..2d10be8 --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerUtils.java @@ -0,0 +1,17 @@ +package com.heyongrui.base.widget.tickerview; + +/** + * Created by lambert on 2018/10/11. + */ + +public class TickerUtils { + static final char EMPTY_CHAR = (char) 0; + + public static String provideNumberList() { + return "0123456789"; + } + + public static String provideAlphabeticalList() { + return "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + } +} diff --git a/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerView.java b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerView.java new file mode 100644 index 0000000..2127b33 --- /dev/null +++ b/base/src/main/java/com/heyongrui/base/widget/tickerview/TickerView.java @@ -0,0 +1,602 @@ +package com.heyongrui.base.widget.tickerview; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.os.Build; +import android.text.TextPaint; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Interpolator; + +import com.heyongrui.base.R; + + +/** + * Created by lambert on 2018/10/11. + */ + +public class TickerView extends View { + private static final int DEFAULT_TEXT_SIZE = 12; + private static final int DEFAULT_TEXT_COLOR = Color.BLACK; + private static final int DEFAULT_ANIMATION_DURATION = 350; + private static final Interpolator DEFAULT_ANIMATION_INTERPOLATOR = + new AccelerateDecelerateInterpolator(); + private static final int DEFAULT_GRAVITY = Gravity.START; + + protected final Paint textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); + + private final TickerDrawMetrics metrics = new TickerDrawMetrics(textPaint); + private final TickerColumnManager columnManager = new TickerColumnManager(metrics); + private final ValueAnimator animator = ValueAnimator.ofFloat(1f); + + // Minor optimizations for re-positioning the canvas for the composer. + private final Rect viewBounds = new Rect(); + + private String text; + + private int lastMeasuredDesiredWidth, lastMeasuredDesiredHeight; + + // View attributes, defaults are set in init(). + private int gravity; + private int textColor; + private float textSize; + private int textStyle; + private long animationDelayInMillis; + private long animationDurationInMillis; + private Interpolator animationInterpolator; + private boolean animateMeasurementChange; + // pending text set from XML because we didn't have a character list initially + private String pendingTextToSet; + + public TickerView(Context context) { + super(context); + init(context, null, 0, 0); + } + + public TickerView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs, 0, 0); + } + + public TickerView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs, defStyleAttr, 0); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public TickerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs, defStyleAttr, defStyleRes); + } + + /** + * We currently only support the following set of XML attributes: + *

+ * + * @param context context from constructor + * @param attrs attrs from constructor + * @param defStyleAttr defStyleAttr from constructor + * @param defStyleRes defStyleRes from constructor + */ + protected void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + final Resources res = context.getResources(); + final StyledAttributes styledAttributes = new StyledAttributes(res); + + // Set the view attributes from XML or from default values defined in this class + final TypedArray arr = context.obtainStyledAttributes(attrs, R.styleable.TickerView, + defStyleAttr, defStyleRes); + + final int textAppearanceResId = arr.getResourceId( + R.styleable.TickerView_android_textAppearance, -1); + + // Check textAppearance first + if (textAppearanceResId != -1) { + final TypedArray textAppearanceArr = context.obtainStyledAttributes( + textAppearanceResId, R.styleable.TickerView); + styledAttributes.applyTypedArray(textAppearanceArr); + textAppearanceArr.recycle(); + } + + // Custom set attributes on the view should override textAppearance if applicable. + styledAttributes.applyTypedArray(arr); + + // After we've fetched the correct values for the attributes, set them on the view + animationInterpolator = DEFAULT_ANIMATION_INTERPOLATOR; + this.animationDurationInMillis = arr.getInt( + R.styleable.TickerView_ticker_animationDuration, DEFAULT_ANIMATION_DURATION); + this.animateMeasurementChange = arr.getBoolean( + R.styleable.TickerView_ticker_animateMeasurementChange, false); + this.gravity = styledAttributes.gravity; + + if (styledAttributes.shadowColor != 0) { + textPaint.setShadowLayer(styledAttributes.shadowRadius, styledAttributes.shadowDx, + styledAttributes.shadowDy, styledAttributes.shadowColor); + } + if (styledAttributes.textStyle != 0) { + textStyle = styledAttributes.textStyle; + setTypeface(textPaint.getTypeface()); + } + + setTextColor(styledAttributes.textColor); + setTextSize(styledAttributes.textSize); + + final int defaultCharList = + arr.getInt(R.styleable.TickerView_ticker_defaultCharacterList, 0); + switch (defaultCharList) { + case 1: + setCharacterLists(TickerUtils.provideNumberList()); + break; + case 2: + setCharacterLists(TickerUtils.provideAlphabeticalList()); + break; + default: + if (isInEditMode()) { + setCharacterLists(TickerUtils.provideNumberList()); + } + } + + if (isCharacterListsSet()) { + setText(styledAttributes.text, false); + } else { + this.pendingTextToSet = styledAttributes.text; + } + + arr.recycle(); + + animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + columnManager.setAnimationProgress( + animation.getAnimatedFraction()); + checkForRelayout(); + invalidate(); + } + }); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + columnManager.onAnimationEnd(); + checkForRelayout(); + invalidate(); + } + }); + } + + /** + * Only attributes that can be applied from `android:textAppearance` should be added here. + */ + private class StyledAttributes { + int gravity; + int shadowColor; + float shadowDx; + float shadowDy; + float shadowRadius; + String text; + int textColor; + float textSize; + int textStyle; + + StyledAttributes(Resources res) { + textColor = DEFAULT_TEXT_COLOR; + textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, + DEFAULT_TEXT_SIZE, res.getDisplayMetrics()); + gravity = DEFAULT_GRAVITY; + } + + void applyTypedArray(TypedArray arr) { + gravity = arr.getInt(R.styleable.TickerView_android_gravity, gravity); + shadowColor = arr.getColor(R.styleable.TickerView_android_shadowColor, + shadowColor); + shadowDx = arr.getFloat(R.styleable.TickerView_android_shadowDx, shadowDx); + shadowDy = arr.getFloat(R.styleable.TickerView_android_shadowDy, shadowDy); + shadowRadius = arr.getFloat(R.styleable.TickerView_android_shadowRadius, + shadowRadius); + text = arr.getString(R.styleable.TickerView_android_text); + textColor = arr.getColor(R.styleable.TickerView_android_textColor, textColor); + textSize = arr.getDimension(R.styleable.TickerView_android_textSize, textSize); + textStyle = arr.getInt(R.styleable.TickerView_android_textStyle, textStyle); + } + } + + + /********** BEGIN PUBLIC API **********/ + + + /** + * This is the primary class that Ticker uses to determine how to animate from one character + * to another. The provided strings dictates what characters will appear between + * the start and end characters. + *

+ *

For example, given the string "abcde", if the view wants to animate from 'd' to 'b', + * it will know that it has to go from 'd' to 'c' to 'b', and these are the characters + * that show up during the animation scroll. + *

+ *

We allow for multiple character lists, and the character lists will be prioritized with + * latter lists given a higher priority than the previous lists. e.g. given "123" and "13", + * an animation from 1 to 3 will use the sequence [1,3] rather than [1,2,3]. + *

+ *

You can find some helpful character list in {@link TickerUtils}. + * + * @param characterLists the list of character lists that dictates animation. + */ + public void setCharacterLists(String... characterLists) { + columnManager.setCharacterLists(characterLists); + if (pendingTextToSet != null) { + setText(pendingTextToSet, false); + pendingTextToSet = null; + } + } + + /** + * @return whether or not the character lists (via {@link #setCharacterLists}) have been set. + * Can use this value to determine if you need to call {@link #setCharacterLists} + * before calling {@link #setText}. + */ + public boolean isCharacterListsSet() { + return columnManager.getCharacterLists() != null; + } + + /** + * Sets the string value to display. If the TickerView is currently empty, then this method + * will immediately display the provided text. Otherwise, it will run the default animation + * to reach the provided text. + * + * @param text the text to display. + */ + public void setText(String text) { + setText(text, !TextUtils.isEmpty(this.text)); + } + + /** + * animate to the provided text or not. + * + * @param text the text to display. + * @param animate whether to animate to text. + */ + public void setText(String text, boolean animate) { + if (TextUtils.equals(text, this.text)) { + return; + } + + this.text = text; + final char[] targetText = text == null ? new char[0] : text.toCharArray(); + + columnManager.setText(targetText); + setContentDescription(text); + + if (animate) { + // Kick off the animator that draws the transition + if (animator.isRunning()) { + animator.cancel(); + } + + animator.setStartDelay(animationDelayInMillis); + animator.setDuration(animationDurationInMillis); + animator.setInterpolator(animationInterpolator); + animator.start(); + } else { + columnManager.setAnimationProgress(1f); + columnManager.onAnimationEnd(); + checkForRelayout(); + invalidate(); + } + } + + /** + * Get the last set text on the view. This does not equate to the current shown text on the + * UI because the animation might not have started or finished yet. + * + * @return last set text on this view. + */ + public String getText() { + return text; + } + + /** + * @return the current text color that's being used to draw the text. + */ + public int getTextColor() { + return textColor; + } + + /** + * Sets the text color used by this view. The default text color is defined by + * {@link #DEFAULT_TEXT_COLOR}. + * + * @param color the color to set the text to. + */ + public void setTextColor(int color) { + if (this.textColor != color) { + textColor = color; + textPaint.setColor(textColor); + invalidate(); + } + } + + /** + * @return the current text size that's being used to draw the text. + */ + public float getTextSize() { + return textSize; + } + + /** + * Sets the text size used by this view. The default text size is defined by + * {@link #DEFAULT_TEXT_SIZE}. + * + * @param textSize the text size in pixel units. + */ + public void setTextSize(float textSize) { + if (this.textSize != textSize) { + this.textSize = textSize; + textPaint.setTextSize(textSize); + onTextPaintMeasurementChanged(); + } + } + + /** + * @return the current text typeface. + */ + public Typeface getTypeface() { + return textPaint.getTypeface(); + } + + /** + * Sets the typeface size used by this view. + * + * @param typeface the typeface to use on the text. + */ + public void setTypeface(Typeface typeface) { + if (textStyle == Typeface.BOLD_ITALIC) { + typeface = Typeface.create(typeface, Typeface.BOLD_ITALIC); + } else if (textStyle == Typeface.BOLD) { + typeface = Typeface.create(typeface, Typeface.BOLD); + } else if (textStyle == Typeface.ITALIC) { + typeface = Typeface.create(typeface, Typeface.ITALIC); + } + + textPaint.setTypeface(typeface); + onTextPaintMeasurementChanged(); + } + + /** + * @return the delay in milliseconds before the transition animations runs + */ + public long getAnimationDelay() { + return animationDelayInMillis; + } + + /** + * Sets the delay in milliseconds before this TickerView runs its transition animations. The + * default animation delay is 0. + * + * @param animationDelayInMillis the delay in milliseconds. + */ + public void setAnimationDelay(long animationDelayInMillis) { + this.animationDelayInMillis = animationDelayInMillis; + } + + /** + * @return the duration in milliseconds that the transition animations run for. + */ + public long getAnimationDuration() { + return animationDurationInMillis; + } + + /** + * Sets the duration in milliseconds that this TickerView runs its transition animations. The + * default animation duration is defined by {@link #DEFAULT_ANIMATION_DURATION}. + * + * @param animationDurationInMillis the duration in milliseconds. + */ + public void setAnimationDuration(long animationDurationInMillis) { + this.animationDurationInMillis = animationDurationInMillis; + } + + /** + * @return the interpolator used to interpolate the animated values. + */ + public Interpolator getAnimationInterpolator() { + return animationInterpolator; + } + + /** + * Sets the interpolator for the transition animation. The default interpolator is defined by + * {@link #DEFAULT_ANIMATION_INTERPOLATOR}. + * + * @param animationInterpolator the interpolator for the animation. + */ + public void setAnimationInterpolator(Interpolator animationInterpolator) { + this.animationInterpolator = animationInterpolator; + } + + /** + * @return the current text gravity used to align the text. Should be one of the values defined + * in {@link Gravity}. + */ + public int getGravity() { + return gravity; + } + + /** + * Sets the gravity used to align the text. + * + * @param gravity the new gravity, should be one of the values defined in + * {@link Gravity}. + */ + public void setGravity(int gravity) { + if (this.gravity != gravity) { + this.gravity = gravity; + invalidate(); + } + } + + /** + * Enables/disables the flag to animate measurement changes. If this flag is enabled, any + * animation that changes the content's text width (e.g. 9999 to 10000) will have the view's + * measured width animated along with the text width. However, a side effect of this is that + * the entering/exiting character might get truncated by the view's view bounds as the width + * shrinks or expands. + *

+ *

Warning: using this feature may degrade performance as it will force a re-measure and + * re-layout during each animation frame. + *

+ *

This flag is disabled by default. + * + * @param animateMeasurementChange whether or not to animate measurement changes. + */ + public void setAnimateMeasurementChange(boolean animateMeasurementChange) { + this.animateMeasurementChange = animateMeasurementChange; + } + + /** + * @return whether or not we are currently animating measurement changes. + */ + public boolean getAnimateMeasurementChange() { + return animateMeasurementChange; + } + + /** + * Adds a custom {@link Animator.AnimatorListener} to listen to animator + * update events used by this view. + * + * @param animatorListener the custom animator listener. + */ + public void addAnimatorListener(Animator.AnimatorListener animatorListener) { + animator.addListener(animatorListener); + } + + /** + * Removes the specified custom {@link Animator.AnimatorListener} from + * this view. + * + * @param animatorListener the custom animator listener. + */ + public void removeAnimatorListener(Animator.AnimatorListener animatorListener) { + animator.removeListener(animatorListener); + } + + + /********** END PUBLIC API **********/ + + + /** + * Force the view to call {@link #requestLayout()} if the new text doesn't match the old bounds + * we set for the previous view state. + */ + private void checkForRelayout() { + final boolean widthChanged = lastMeasuredDesiredWidth != computeDesiredWidth(); + final boolean heightChanged = lastMeasuredDesiredHeight != computeDesiredHeight(); + + if (widthChanged || heightChanged) { + requestLayout(); + } + } + + private int computeDesiredWidth() { + final int contentWidth = (int) (animateMeasurementChange ? + columnManager.getCurrentWidth() : columnManager.getMinimumRequiredWidth()); + return contentWidth + getPaddingLeft() + getPaddingRight(); + } + + private int computeDesiredHeight() { + return (int) metrics.getCharHeight() + getPaddingTop() + getPaddingBottom(); + } + + /** + * Re-initialize all of our variables that are dependent on the TextPaint measurements. + */ + private void onTextPaintMeasurementChanged() { + metrics.invalidate(); + checkForRelayout(); + invalidate(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + lastMeasuredDesiredWidth = computeDesiredWidth(); + lastMeasuredDesiredHeight = computeDesiredHeight(); + + int desiredWidth = resolveSize(lastMeasuredDesiredWidth, widthMeasureSpec); + int desiredHeight = resolveSize(lastMeasuredDesiredHeight, heightMeasureSpec); + + setMeasuredDimension(desiredWidth, desiredHeight); + } + + @Override + protected void onSizeChanged(int width, int height, int oldw, int oldh) { + super.onSizeChanged(width, height, oldw, oldh); + viewBounds.set(getPaddingLeft(), getPaddingTop(), width - getPaddingRight(), + height - getPaddingBottom()); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + canvas.save(); + + realignAndClipCanvasForGravity(canvas); + + // canvas.drawText writes the text on the baseline so we need to translate beforehand. + canvas.translate(0f, metrics.getCharBaseline()); + + columnManager.draw(canvas, textPaint); + + canvas.restore(); + } + + private void realignAndClipCanvasForGravity(Canvas canvas) { + final float currentWidth = columnManager.getCurrentWidth(); + final float currentHeight = metrics.getCharHeight(); + realignAndClipCanvasForGravity(canvas, gravity, viewBounds, currentWidth, currentHeight); + } + + // VisibleForTesting + static void realignAndClipCanvasForGravity(Canvas canvas, int gravity, Rect viewBounds, + float currentWidth, float currentHeight) { + final int availableWidth = viewBounds.width(); + final int availableHeight = viewBounds.height(); + + float translationX = 0; + float translationY = 0; + if ((gravity & Gravity.CENTER_VERTICAL) == Gravity.CENTER_VERTICAL) { + translationY = viewBounds.top + (availableHeight - currentHeight) / 2f; + } + if ((gravity & Gravity.CENTER_HORIZONTAL) == Gravity.CENTER_HORIZONTAL) { + translationX = viewBounds.left + (availableWidth - currentWidth) / 2f; + } + if ((gravity & Gravity.TOP) == Gravity.TOP) { + translationY = 0; + } + if ((gravity & Gravity.BOTTOM) == Gravity.BOTTOM) { + translationY = viewBounds.top + (availableHeight - currentHeight); + } + if ((gravity & Gravity.START) == Gravity.START) { + translationX = 0; + } + if ((gravity & Gravity.END) == Gravity.END) { + translationX = viewBounds.left + (availableWidth - currentWidth); + } + + canvas.translate(translationX, translationY); + canvas.clipRect(0f, 0f, currentWidth, currentHeight); + } +} diff --git a/main/src/main/java/com/heyongrui/main/HomeFragment.java b/main/src/main/java/com/heyongrui/main/HomeFragment.java index 91417f4..46fe96d 100644 --- a/main/src/main/java/com/heyongrui/main/HomeFragment.java +++ b/main/src/main/java/com/heyongrui/main/HomeFragment.java @@ -1,12 +1,25 @@ package com.heyongrui.main; import android.os.Bundle; +import android.view.View; +import android.widget.TextView; import com.alibaba.android.arouter.launcher.ARouter; import com.heyongrui.base.assist.ConfigConstants; import com.heyongrui.base.base.BaseFragment; +import com.heyongrui.base.widget.numberruntextview.NumberRunningTextView; +import com.heyongrui.base.widget.tickerview.TickerUtils; +import com.heyongrui.base.widget.tickerview.TickerView; -public class HomeFragment extends BaseFragment { +import java.util.Random; + +public class HomeFragment extends BaseFragment implements View.OnClickListener { + + + private TickerView tickerView; + private NumberRunningTextView numberRunTv; + + private int tickerCount; public static HomeFragment getInstance() { HomeFragment fragment = new HomeFragment(); @@ -23,17 +36,49 @@ protected int getLayoutId() { } @Override - protected void initView(Bundle savedInstanceState) { - addOnClickListeners(view -> { - int id = view.getId(); - if (id == R.id.tv_home) { - ARouter.getInstance().build(ConfigConstants.PATH_KOTLIN).navigation(); + public void onClick(View v) { + int id = v.getId(); + if (id == R.id.tv_home) { + ARouter.getInstance().build(ConfigConstants.PATH_KOTLIN).navigation(); + } else if (id == R.id.ticker_view) { + if (tickerCount < 5) { + tickerView.setCharacterLists(TickerUtils.provideNumberList()); + tickerView.setText("¥" + new Random().nextInt(500) + "." + new Random().nextInt(100)); + } else if (tickerCount < 10 && tickerCount >= 5) { + tickerView.setCharacterLists(TickerUtils.provideAlphabeticalList()); + int digits = new Random().nextInt(2) + 6; + tickerView.setText(generateChars(new Random(), TickerUtils.provideAlphabeticalList(), digits)); + } + if (tickerCount == 10) { + tickerCount = 0; + } else { + tickerCount++; } - }, R.id.tv_home); + } else if (id == R.id.number_run_tv) { + numberRunTv.setContent(new Random().nextInt(500) + ".47"); + } + } + + @Override + protected void initView(Bundle savedInstanceState) { + tickerView = mView.findViewById(R.id.ticker_view); + numberRunTv = mView.findViewById(R.id.number_run_tv); + TextView tvHome = mView.findViewById(R.id.tv_home); + + addOnClickListeners(this, tvHome); } @Override protected void initData(Bundle savedInstanceState) { } + + private String generateChars(Random random, String list, int numDigits) { + final char[] result = new char[numDigits]; + for (int i = 0; i < numDigits; i++) { + result[i] = list.charAt(random.nextInt(list.length())); + } + return new String(result); + } + } diff --git a/main/src/main/java/com/heyongrui/main/IHomeFragmentProvider.java b/main/src/main/java/com/heyongrui/main/provider/IHomeFragmentProvider.java similarity index 87% rename from main/src/main/java/com/heyongrui/main/IHomeFragmentProvider.java rename to main/src/main/java/com/heyongrui/main/provider/IHomeFragmentProvider.java index 393125a..40b92cb 100644 --- a/main/src/main/java/com/heyongrui/main/IHomeFragmentProvider.java +++ b/main/src/main/java/com/heyongrui/main/provider/IHomeFragmentProvider.java @@ -1,4 +1,4 @@ -package com.heyongrui.main; +package com.heyongrui.main.provider; import android.content.Context; @@ -6,6 +6,7 @@ import com.heyongrui.base.assist.ConfigConstants; import com.heyongrui.base.base.BaseFragment; import com.heyongrui.base.provider.IFragmentProvider; +import com.heyongrui.main.HomeFragment; @Route(path = ConfigConstants.PATH_HOME_PROVIDER) public class IHomeFragmentProvider implements IFragmentProvider { diff --git a/main/src/main/res/layout/fragment_home.xml b/main/src/main/res/layout/fragment_home.xml index a338cae..8ada212 100644 --- a/main/src/main/res/layout/fragment_home.xml +++ b/main/src/main/res/layout/fragment_home.xml @@ -4,6 +4,33 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + +