今天我們要實現一個如下圖的軌跡動畫:
首先我們來分析一下實現原理,大致就是做一個字母移動路徑動畫,但仔細觀察會發現字母移動過程中字母大小跟要拼接的單詞的大小相同;同時我們會發現我們不能直接移動字母列表中的字母這個view,移動之後這個字母項就沒有了,顯示爲空白,所以我想我可以再畫一個單獨用來移動的字母的view,讓這個單獨的view來執行軌跡動畫。這樣就可以解決字母item被移走的問題了。好了,大致原理說清楚了,我們來實現以下。
首先我們來看下實現需要用到的知識點:
軌跡動畫
實現思路:
1、首先創建移動路徑;
2、用PathMeasure
(參考:Android動畫進階PathMeasure)計算移動點的座標;
3、添加屬性動畫監聽
4、設置view的座標;
代碼如下:
Path path = new Path();
path.moveTo(srcPoint[0], srcPoint[1]);
path.lineTo(dstPoint[0], dstPoint[1]);
//pathMeasure用來計算顯示座標
PathMeasure pathMeasure = new PathMeasure(path, false);
//屬性動畫加載
valueAnimator = ValueAnimator.ofFloat(0, pathMeasure.getLength());
valueAnimator.setDuration(500);
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.addUpdateListener(valueAnimator -> {
float value = (float) valueAnimator.getAnimatedValue();
float[] curPos = new float[2];
pathMeasure.getPosTan(value, curPos, null);
view.setX(curPos[0]);
view.setY(curPos[1]);
});
獲取TextView指定字符的XY座標
Google給我們提供了一個類——Layout
,layout 提供了一系列的操作文字元素的 api,如下:
getLineForOffset(int offset)
獲取指定字符的行號;getLineBounds(int line, Rect bounds)
獲取指定行的所在的區域;getPrimaryHorizontal(int offset)
獲取指定字符的左座標;getSecondaryHorizontal(int offset)
獲取指定字符的輔助水平偏移量;getLineMax(int line)
獲取指定行的寬度,包含縮進但是不包含後面的空白,可以認爲是獲取文本區域顯示出來的一行的寬度;getLineStart(int line)
獲取指定行的第一個字符的下標;
瞭解這些方法以後再來考慮如何獲取TextView中指定字符的座標就會變得輕而易舉了吧。
1、首先我們要獲取TextView的layout
2、獲取指定字符所在的行數
3、獲取指定行數所佔的區域
4、獲取指定字符座標(這裏由於我們要讓字母動畫結束時剛好和單詞的每個字母位置完全匹配所以我們需要再得到字符y座標的基礎上加上字母到TextView頂部的距離=fontMetrics.top - fontMetrics.ascent
;這裏不理解的同學可以參考我前面的文章Android——SpannableString字體大小不一致垂直居中)
具體代碼如下:
private int[] getCharLocationInText(TextView textView, int position) {
int[] outLocation = getLocationInWindow(textView);
Layout layout = textView.getLayout();
Rect rect = new Rect();
int line = layout.getLineForOffset(position);
layout.getLineBounds(line, rect);
Paint.FontMetrics fontMetrics = textView.getPaint().getFontMetrics();
int textAscent = -(int) (fontMetrics.top - fontMetrics.ascent);//計算文字距離TextView頂部的距離
outLocation[0] += (int) layout.getPrimaryHorizontal(position);
outLocation[1] += textAscent;//outLocation[1]是TextView的y座標,這裏我們要計算的是文字的y座標,所以要加上頂部的距離
return outLocation;
}
部分關鍵代碼如下:
PathAnimator .java
public class PathAnimator {
private static ValueAnimator valueAnimator;
public static void start(View view, int[] srcPoint, int[] dstPoint, PathAnimListener pathAnimListener) {
Path path = new Path();
path.moveTo(srcPoint[0], srcPoint[1]);
path.lineTo(dstPoint[0], dstPoint[1]);
//pathMeasure用來計算顯示座標
PathMeasure pathMeasure = new PathMeasure(path, false);
//屬性動畫加載
valueAnimator = ValueAnimator.ofFloat(0, pathMeasure.getLength());
valueAnimator.setDuration(500);
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.addUpdateListener(valueAnimator -> {
float value = (float) valueAnimator.getAnimatedValue();
float[] curPos = new float[2];
pathMeasure.getPosTan(value, curPos, null);
view.setX(curPos[0]);
view.setY(curPos[1]);
});
valueAnimator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
if (pathAnimListener != null)
pathAnimListener.onAnimationEnd();
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
valueAnimator.start();
}
public static void release() {
if (valueAnimator != null) {
valueAnimator.cancel();
valueAnimator = null;
}
}
public interface PathAnimListener {
void onAnimationEnd();
}
}
private LetterAdapter letterAdapter;
private TagAdapter<String> tagAdapter;
private List<String> wordList;
private int letterTargetPos = 0;//單詞字母目標對比位置
private Disposable changeWordDisposable, startJointDisposable, endJointDisposable;
private ObjectAnimator shakeAnimator;
@OnClick(R.id.btn_word_voice)
public void onViewClicked() {
playWordVoice(null);
}
private void initFlowlayout() {
//初始化按鍵音頻模塊
SoundPoolUtils.getInstance().init(this);
//初始化單詞讀音播放模塊
WordAudioUtils.getInstance().init();
// TODO: 2019/10/15 接口獲取
wordList = new ArrayList<>();
wordList.add("dog");
wordList.add("bird");
wordList.add("tiger");
wordList.add("cat");
wordList.add("monkeys");
wordList.add("what");
wordList.add("it");
tagAdapter = new TagAdapter<String>(wordList) {
@Override
public View getView(FlowLayout parent, int position, String text) {
TextView tv = (TextView) LayoutInflater.from(MainActivity.this).inflate(R.layout.layout_flow_item,
parent, false);//parent設爲null,自帶間距,parent設爲flowLayout沒有間距,需要自己處理
tv.setText(text);
return tv;
}
};
flowlayout.setAdapter(tagAdapter);
flowlayout.setOnTagClickListener(new TagFlowLayout.OnTagClickListener() {
@Override
public boolean onTagClick(View view, int position, FlowLayout parent) {
String word = wordList.get(position);
//設置練習單詞
onChangeJointWord(word);
return false;
}
});
letterAdapter = new LetterAdapter();
letterAdapter.setOnClickListener((view, letter) -> {
//判斷字母是否選擇正確
Character targetLetter = getCurWord().charAt(letterTargetPos);
if (targetLetter == letter) {//正確
//播放選擇正確音樂 -叮
playLetterBtnMusic(true);
//字母軌跡動畫
startLetterPathAnim(view, letter);
} else {
//播放選擇錯誤音樂 -叮
playLetterBtnMusic(false);
//如果錯誤做抖動動畫
shakeAnim(view);
}
});
listLetters.setAdapter(letterAdapter);
//初始化第一個單詞
setJointWord(0);
}
private List<Character> getWordLetters(String word) {
List<Character> letters = new ArrayList<>();
for (int i = 0; i < word.length(); i++) {
letters.add(word.charAt(i));
}
return letters;
}
/**
* 設置拼接單詞
*/
private void setJointWord(int wordPos) {
flowlayout.getChildAt(wordPos).performClick();
//直接調用changeJointWord(),flowlayout中的tag樣式不會有變化,還需自己改變
}
/**
* 切換拼接單詞
*/
private void onChangeJointWord(String word) {
changeBeforeWordColor(wordList.indexOf(word));
setWord(word);
setLetterList(word);
startJoint();
}
/**
* 設置單詞
*
* @param word
*/
private void setWord(String word) {
//設置拼接單詞
tvWord.setText(word);
tvWord.setTextColor(getColor_(R.color.white));
}
/**
* 設置字母列表
*
* @param word
*/
private void setLetterList(String word) {
//更換單詞重新初始化目標字母位置
letterTargetPos = 0;
//設置單詞字母列表(打亂順序)
layoutLetterList.setVisibility(View.INVISIBLE);
letterAdapter.setDatas(getWordLetters(word));
}
private void changeBeforeWordColor(int curWordPos) {
//這個單詞之前的單詞全部改成已拼接完成的顏色
for (int i = 0; i < flowlayout.getChildCount(); i++) {
TextView word = (TextView) ((TagView) flowlayout.getChildAt(i)).getTagView();
word.setTextColor(getColor_(i < curWordPos ? R.color.red : R.color.blue));
}
}
/**
* 開始單詞拼接
*/
private void startJoint() {
//讀音,先用白色,讀音結束後變成透明色
playWordVoice(() -> {
tvWord.setTextColor(getColor_(R.color.black_50));
layoutLetterList.setVisibility(View.VISIBLE);
});
}
private void endJoint() {
//幾秒後文字變黃
Observable.timer(500, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io()).subscribe(new Observer<Long>() {
@Override
public void onSubscribe(Disposable d) {
endJointDisposable = d;
}
@Override
public void onNext(Long aLong) {
//如果全部選擇完畢,改變單詞整體顏色,並進入下一個單詞
setCurWordColor(R.color.yellow);
int nextWordPos = wordList.indexOf(getCurWord()) + 1;
if (nextWordPos >= flowlayout.getChildCount()) {
// TODO: 2019/10/16 全部完成彈框提示
Toast.makeText(MainActivity.this, "已全部拼接完成", Toast.LENGTH_SHORT).show();
return;
} else {
//幾秒後自動切換單詞
Observable.timer(2, TimeUnit.SECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io()).subscribe(new Observer<Long>() {
@Override
public void onSubscribe(Disposable d) {
changeWordDisposable = d;
}
@Override
public void onNext(Long aLong) {
setJointWord(nextWordPos);
}
@Override
public void onError(Throwable e) {
}
@Override
public void onComplete() {
}
});
}
}
@Override
public void onError(Throwable e) {
}
@Override
public void onComplete() {
}
});
}
private int getColor_(int resColorId) {
return getResources().getColor(resColorId);
}
/**
* 獲取當前單詞
*
* @return
*/
private String getCurWord() {
return tvWord.getText().toString();
}
private void setCurWordColor(int resColorId) {
SpannableString ss = new SpannableString(getCurWord());
ss.setSpan(new ForegroundColorSpan(getColor_(resColorId)), 0, letterTargetPos +
1, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE);
tvWord.setText(ss);
}
private void shakeAnim(View view) {
shakeAnimator = ObjectAnimator.ofFloat(view, "translationX", 0, 10, -10, 0);
shakeAnimator.setDuration(200);
shakeAnimator.start();
}
/**
* 播放字母按鍵音
*
* @param result 字母選擇結果
*/
private void playLetterBtnMusic(boolean result) {
SoundPoolUtils.getInstance().play(result ? SoundPoolUtils.rightId : SoundPoolUtils.wrongId);
}
/**
* 播放單詞發音
*/
private void playWordVoice(WordAudioUtils.WordAudioPlayListener listener) {
String wordVoiceUrl = "http://17zy-content-video.oss-cn-beijing.aliyuncs.com/Tobbit-01/1a-39-11.mp3";
wordVoiceUrl="http://cdn.17zuoye.com/fs-resource/598d45bd2ed9b6789c386110.sy3";
WordAudioUtils.getInstance().play(wordVoiceUrl, listener);//裏面用MediaPlayer實現播放單詞讀音
}
/**
* 字母軌跡動畫
*
* @param letter
*/
private void startLetterPathAnim(View view, Character letter) {
// TODO: 2019/10/16
//設置動畫字母值
tvAnimLetter.setText(String.valueOf(letter));
tvAnimLetter.setVisibility(View.VISIBLE);
int[] srtPoint = getLocationInWindow(view);
int[] dstPoint = getCharLocationInText(tvWord, getCurWord().indexOf(letter));
PathAnimator.start(tvAnimLetter, srtPoint, dstPoint, () -> {
//隱藏動畫字母
tvAnimLetter.setVisibility(View.GONE);
//動畫結束脩改單詞對應字母顏色
setCurWordColor(R.color.white);
//修改已選字母顏色
letterAdapter.setCheckedDatas(letter);
if (letterTargetPos < getCurWord().length() - 1) {
letterTargetPos++;
} else {
//拼接完成,改變單詞整體顏色,幾秒後自動進入下一個單詞
endJoint();
}
});
}
private int[] getLocationInWindow(View view) {
int[] outLocation = new int[2];
view.getLocationInWindow(outLocation);
return outLocation;
}
private int[] getCharLocationInText(TextView textView, int position) {
int[] outLocation = getLocationInWindow(textView);
Layout layout = textView.getLayout();
Rect rect = new Rect();
int line = layout.getLineForOffset(position);
layout.getLineBounds(line, rect);
Paint.FontMetrics fontMetrics = textView.getPaint().getFontMetrics();
int textAscent = -(int) (fontMetrics.top - fontMetrics.ascent);//計算文字距離TextView頂部的距離
outLocation[0] += (int) layout.getPrimaryHorizontal(position);
outLocation[1] += textAscent;//outLocation[1]是TextView的y座標,這裏我們要計算的是文字的y座標,所以要加上頂部的距離
return outLocation;
}
@Override
protected void onDestroy() {
super.onDestroy();
if (changeWordDisposable != null && !changeWordDisposable.isDisposed()) {
changeWordDisposable.dispose();
}
if (startJointDisposable != null && !startJointDisposable.isDisposed()) {
startJointDisposable.dispose();
}
if (endJointDisposable != null && !endJointDisposable.isDisposed()) {
endJointDisposable.dispose();
}
if (shakeAnimator != null) {
shakeAnimator.cancel();
shakeAnimator = null;
}
PathAnimator.release();
SoundPoolUtils.getInstance().release();
WordAudioUtils.getInstance().release();
}
·build.gradle·
implementation 'com.android.support:recyclerview-v7:26.1.0'
implementation 'com.hyman:flowlayout-lib:1.1.2'
//rxjava2
implementation 'io.reactivex.rxjava2:rxjava:2.1.4'
//rxandroid
implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'