diff --git a/app/src/main/java/me/chan/texas/TexasViewDemoActivity.java b/app/src/main/java/me/chan/texas/TexasViewDemoActivity.java index 2e74b5a0..557681c7 100644 --- a/app/src/main/java/me/chan/texas/TexasViewDemoActivity.java +++ b/app/src/main/java/me/chan/texas/TexasViewDemoActivity.java @@ -1,12 +1,16 @@ package me.chan.texas; import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Typeface; import android.graphics.drawable.Drawable; +import android.os.Build; import android.os.Bundle; import android.util.Log; import android.util.TypedValue; @@ -17,6 +21,7 @@ import android.widget.Toast; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; @@ -30,6 +35,7 @@ import me.chan.texas.text.BreakStrategy; import me.chan.texas.text.Document; import me.chan.texas.text.Paragraph; +import me.chan.texas.text.Segment; import me.chan.texas.utils.TexasUtils; public class TexasViewDemoActivity extends AppCompatActivity { @@ -367,6 +373,41 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { renderOption.setTypeface(typeface); mTexasView.refresh(renderOption); + final Object ADD = new Object(); + mTexasView.setSegmentAnimator(new TexasView.SegmentAnimator() { + + @Override + protected Animator onCreateAddAnimator(Segment segment, View itemView) { + if (segment.getTag() != ADD) { + return null; + } + + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.play(ObjectAnimator.ofFloat(itemView, "alpha", 0, 1)) + .with(ObjectAnimator.ofFloat(itemView, "translationY", -itemView.getHeight(), 0)); + animatorSet.setDuration(500); + animatorSet.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(Animator animation) { + super.onAnimationCancel(animation); + itemView.setAlpha(1); + itemView.setTranslationY(0); + } + }); + return animatorSet; + } + + @Override + protected Animator onCreateRemoveAnimator(Segment segment, View view) { + return null; + } + + @Override + protected Animator onCreateMoveAnimator(Segment segment, View view, int fromX, int fromY, int toX, int toY) { + return null; + } + }); + findViewById(me.chan.texas.debug.R.id.add_content).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { @@ -375,8 +416,10 @@ public void onClick(View v) { protected Document onRead(TexasOption option, @Nullable Document previousDocument) { return new Document.Builder(previousDocument) .addSegment( + 0, Paragraph.Builder.newBuilder(option) - .text("hello world") + .tag(ADD) + .text("生活就像点菜,饥饿时菜会点得特别多,但吃一阵就会意识到浪费;如果慢条斯理地盘算怎么点菜,别人已经要吃完了。") .build() ) .build(); @@ -385,6 +428,9 @@ protected Document onRead(TexasOption option, @Nullable Document previousDocumen } }); + Selection.Styles styles = Selection.Styles.create(Color.BLUE, Color.RED); + styles.enableFakeBold(); + findViewById(me.chan.texas.debug.R.id.anim).setOnClickListener(v -> { Selection selection = mTexasView.highlightParagraphs(new ParagraphPredicates() { @Override @@ -401,17 +447,20 @@ public boolean acceptParagraph(@Nullable Object paragraphTag) { return; } + int backgroundColor = styles.getBackgroundColor(); + int textColor = styles.getTextColor(); + float fakeBoldFactor = styles.getFakeBoldFactor(); ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 1f); valueAnimator.setDuration(3000); valueAnimator.setRepeatCount(3); selection.startAnimator(valueAnimator, new Selection.SelectionAnimatorListener() { + @RequiresApi(api = Build.VERSION_CODES.O) @Override protected void onUpdate(ValueAnimator animation, Selection.Styles styles) { - int backgroundColor = (int) styles.getBackgroundColor(); - int textColor = styles.getTextColor(); float v = (float) animation.getAnimatedValue(); - styles.setTextColor(Color.argb((int) (255 * v), Color.red(textColor), Color.green(textColor), Color.blue(textColor))); - styles.setBackgroundColor(Color.argb((int) (255 * v), Color.red(backgroundColor), Color.green(backgroundColor), Color.blue(backgroundColor))); + styles.setTextColor(Color.argb((int) (255 * v) /* 按需设置透明度 */, Color.red(textColor), Color.green(textColor), Color.blue(textColor))); + styles.setBackgroundColor(Color.argb((int) (255 * v) /* 按需设置透明度 */, Color.red(backgroundColor), Color.green(backgroundColor), Color.blue(backgroundColor))); + styles.setFakeBoldFactor(fakeBoldFactor * v); } @Override diff --git a/app/src/main/res/layout/activity_paragraph.xml b/app/src/main/res/layout/activity_paragraph.xml index 899ca2c9..ee474a23 100644 --- a/app/src/main/res/layout/activity_paragraph.xml +++ b/app/src/main/res/layout/activity_paragraph.xml @@ -74,7 +74,7 @@ android:id="@+id/anim" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="动画" /> + android:text="选中动画" /> 0f) { + textPaint.setStyle(Paint.Style.FILL_AND_STROKE); + textPaint.setStrokeWidth(textPaint.getTextSize() * mFakeBoldFactor); + } } } } diff --git a/library/src/main/java/me/chan/texas/renderer/selection/Selection.java b/library/src/main/java/me/chan/texas/renderer/selection/Selection.java index cc9bb3a7..9fa6e426 100644 --- a/library/src/main/java/me/chan/texas/renderer/selection/Selection.java +++ b/library/src/main/java/me/chan/texas/renderer/selection/Selection.java @@ -363,6 +363,7 @@ public static Selection obtain(Type type, @Nullable TexasRecyclerView container, public static class Styles { private int mBackgroundColor; private int mTextColor; + private float mFakeBoldFactor = 0f; private Source mSource; @@ -424,6 +425,21 @@ public void setTextColor(int textColor) { mTextColor = textColor; } + public void enableFakeBold() { + setFakeBoldFactor(0.05f); + } + + public void setFakeBoldFactor(float fakeBoldFactor) { + if (mFakeBoldFactor != fakeBoldFactor) { + ++mVersion; + } + mFakeBoldFactor = fakeBoldFactor; + } + + public float getFakeBoldFactor() { + return mFakeBoldFactor; + } + @Override public String toString() { return "Styles{" + diff --git a/library/src/main/java/me/chan/texas/renderer/ui/rv/DefaultItemAnimator.java b/library/src/main/java/me/chan/texas/renderer/ui/rv/DefaultItemAnimator.java new file mode 100644 index 00000000..f27fdc21 --- /dev/null +++ b/library/src/main/java/me/chan/texas/renderer/ui/rv/DefaultItemAnimator.java @@ -0,0 +1,626 @@ +package me.chan.texas.renderer.ui.rv; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.TimeInterpolator; +import android.animation.ValueAnimator; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; + +import java.util.ArrayList; +import java.util.List; + +import me.chan.texas.R; +import me.chan.texas.renderer.TexasView; +import me.chan.texas.text.Segment; + +/** + * This implementation of {@link RecyclerView.ItemAnimator} provides basic + * animations on remove, add, and move events that happen to the items in + * a RecyclerView. RecyclerView uses a DefaultItemAnimator by default. + * + * @see RecyclerView#setItemAnimator(RecyclerView.ItemAnimator) + */ +class DefaultItemAnimator extends SimpleItemAnimator { + @Nullable + private TexasView.SegmentAnimator mSegmentItemAnimator; + + private static TimeInterpolator sDefaultInterpolator; + + private final ArrayList mPendingRemovals = new ArrayList<>(); + private final ArrayList mPendingAdditions = new ArrayList<>(); + private final ArrayList mPendingMoves = new ArrayList<>(); + private final ArrayList mPendingChanges = new ArrayList<>(); + + ArrayList> mAdditionsList = new ArrayList<>(); + ArrayList> mMovesList = new ArrayList<>(); + ArrayList> mChangesList = new ArrayList<>(); + + ArrayList mAddAnimations = new ArrayList<>(); + ArrayList mMoveAnimations = new ArrayList<>(); + ArrayList mRemoveAnimations = new ArrayList<>(); + ArrayList mChangeAnimations = new ArrayList<>(); + + private static class MoveInfo { + public RecyclerView.ViewHolder holder; + public int fromX, fromY, toX, toY; + + MoveInfo(RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { + this.holder = holder; + this.fromX = fromX; + this.fromY = fromY; + this.toX = toX; + this.toY = toY; + } + } + + private static class ChangeInfo { + public RecyclerView.ViewHolder oldHolder, newHolder; + public int fromX, fromY, toX, toY; + + private ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder) { + this.oldHolder = oldHolder; + this.newHolder = newHolder; + } + + ChangeInfo(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, + int fromX, int fromY, int toX, int toY) { + this(oldHolder, newHolder); + this.fromX = fromX; + this.fromY = fromY; + this.toX = toX; + this.toY = toY; + } + + @Override + public String toString() { + return "ChangeInfo{" + + "oldHolder=" + oldHolder + + ", newHolder=" + newHolder + + ", fromX=" + fromX + + ", fromY=" + fromY + + ", toX=" + toX + + ", toY=" + toY + + '}'; + } + } + + public void setSegmentItemAnimator(@Nullable TexasView.SegmentAnimator segmentItemAnimator) { + mSegmentItemAnimator = segmentItemAnimator; + } + + @Override + public void runPendingAnimations() { + boolean removalsPending = !mPendingRemovals.isEmpty(); + boolean movesPending = !mPendingMoves.isEmpty(); + boolean changesPending = !mPendingChanges.isEmpty(); + boolean additionsPending = !mPendingAdditions.isEmpty(); + if (!removalsPending && !movesPending && !additionsPending && !changesPending) { + // nothing to animate + return; + } + // First, remove stuff + for (RecyclerView.ViewHolder holder : mPendingRemovals) { + animateRemoveImpl(holder); + } + mPendingRemovals.clear(); + // Next, move stuff + if (movesPending) { + final ArrayList moves = new ArrayList<>(); + moves.addAll(mPendingMoves); + mMovesList.add(moves); + mPendingMoves.clear(); + Runnable mover = () -> { + for (MoveInfo moveInfo : moves) { + animateMoveImpl(moveInfo.holder, moveInfo.fromX, moveInfo.fromY, + moveInfo.toX, moveInfo.toY); + } + moves.clear(); + mMovesList.remove(moves); + }; + if (removalsPending) { + View view = moves.get(0).holder.itemView; + ViewCompat.postOnAnimationDelayed(view, mover, getRemoveDuration()); + } else { + mover.run(); + } + } + // Next, change stuff, to run in parallel with move animations + if (changesPending) { + final ArrayList changes = new ArrayList<>(); + changes.addAll(mPendingChanges); + mChangesList.add(changes); + mPendingChanges.clear(); + Runnable changer = () -> { + for (ChangeInfo change : changes) { + animateChangeImpl(change); + } + changes.clear(); + mChangesList.remove(changes); + }; + if (removalsPending) { + RecyclerView.ViewHolder holder = changes.get(0).oldHolder; + ViewCompat.postOnAnimationDelayed(holder.itemView, changer, getRemoveDuration()); + } else { + changer.run(); + } + } + // Next, add stuff + if (additionsPending) { + final ArrayList additions = new ArrayList<>(); + additions.addAll(mPendingAdditions); + mAdditionsList.add(additions); + mPendingAdditions.clear(); + Runnable adder = () -> { + for (RecyclerView.ViewHolder holder : additions) { + animateAddImpl(holder); + } + additions.clear(); + mAdditionsList.remove(additions); + }; + if (removalsPending || movesPending || changesPending) { + long removeDuration = removalsPending ? getRemoveDuration() : 0; + long moveDuration = movesPending ? getMoveDuration() : 0; + long changeDuration = changesPending ? getChangeDuration() : 0; + long totalDelay = removeDuration + Math.max(moveDuration, changeDuration); + View view = additions.get(0).itemView; + ViewCompat.postOnAnimationDelayed(view, adder, totalDelay); + } else { + adder.run(); + } + } + } + + @Override + public boolean animateAdd(final RecyclerView.ViewHolder holder) { + resetAnimation(holder); + mPendingAdditions.add(holder); + return true; + } + + @Override + public boolean animateRemove(final RecyclerView.ViewHolder holder) { + resetAnimation(holder); + mPendingRemovals.add(holder); + return true; + } + + @Override + public boolean animateMove(final RecyclerView.ViewHolder holder, int fromX, int fromY, + int toX, int toY) { + fromX += (int) holder.itemView.getTranslationX(); + fromY += (int) holder.itemView.getTranslationY(); + resetAnimation(holder); + int deltaX = toX - fromX; + int deltaY = toY - fromY; + if (deltaX == 0 && deltaY == 0) { + dispatchMoveFinished(holder); + return false; + } + mPendingMoves.add(new MoveInfo(holder, fromX, fromY, toX, toY)); + return true; + } + + @Override + public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, + int fromX, int fromY, int toX, int toY) { + if (oldHolder == newHolder) { + // Don't know how to run change animations when the same view holder is re-used. + // run a move animation to handle position changes. + return animateMove(oldHolder, fromX, fromY, toX, toY); + } + resetAnimation(oldHolder); + if (newHolder != null) { + // carry over translation values + resetAnimation(newHolder); + } + mPendingChanges.add(new ChangeInfo(oldHolder, newHolder, fromX, fromY, toX, toY)); + return true; + } + + @Override + public boolean isRunning() { + return (!mPendingAdditions.isEmpty() + || !mPendingChanges.isEmpty() + || !mPendingMoves.isEmpty() + || !mPendingRemovals.isEmpty() + || !mMoveAnimations.isEmpty() + || !mRemoveAnimations.isEmpty() + || !mAddAnimations.isEmpty() + || !mChangeAnimations.isEmpty() + || !mMovesList.isEmpty() + || !mAdditionsList.isEmpty() + || !mChangesList.isEmpty()); + } + + @Override + public void endAnimation(RecyclerView.ViewHolder item) { + final View view = item.itemView; + // this will trigger end callback which should set properties to their target values. + Animator animator = (Animator) view.getTag(R.id.me_chan_texas_item_anim_tag); + if (animator != null) { + animator.cancel(); + view.setTag(R.id.me_chan_texas_item_anim_tag, null); + } + for (int i = mPendingMoves.size() - 1; i >= 0; i--) { + MoveInfo moveInfo = mPendingMoves.get(i); + if (moveInfo.holder == item) { + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(item); + mPendingMoves.remove(i); + } + } + endChangeAnimation(mPendingChanges, item); + if (mPendingRemovals.remove(item)) { + view.setAlpha(1); + dispatchRemoveFinished(item); + } + if (mPendingAdditions.remove(item)) { + view.setAlpha(1); + dispatchAddFinished(item); + } + + for (int i = mChangesList.size() - 1; i >= 0; i--) { + ArrayList changes = mChangesList.get(i); + endChangeAnimation(changes, item); + if (changes.isEmpty()) { + mChangesList.remove(i); + } + } + for (int i = mMovesList.size() - 1; i >= 0; i--) { + ArrayList moves = mMovesList.get(i); + for (int j = moves.size() - 1; j >= 0; j--) { + MoveInfo moveInfo = moves.get(j); + if (moveInfo.holder == item) { + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(item); + moves.remove(j); + if (moves.isEmpty()) { + mMovesList.remove(i); + } + break; + } + } + } + for (int i = mAdditionsList.size() - 1; i >= 0; i--) { + ArrayList additions = mAdditionsList.get(i); + if (additions.remove(item)) { + view.setAlpha(1); + dispatchAddFinished(item); + if (additions.isEmpty()) { + mAdditionsList.remove(i); + } + } + } + dispatchFinishedWhenDone(); + } + + @Override + public void endAnimations() { + int count = mPendingMoves.size(); + for (int i = count - 1; i >= 0; i--) { + MoveInfo item = mPendingMoves.get(i); + View view = item.holder.itemView; + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(item.holder); + mPendingMoves.remove(i); + } + count = mPendingRemovals.size(); + for (int i = count - 1; i >= 0; i--) { + RecyclerView.ViewHolder item = mPendingRemovals.get(i); + dispatchRemoveFinished(item); + mPendingRemovals.remove(i); + } + count = mPendingAdditions.size(); + for (int i = count - 1; i >= 0; i--) { + RecyclerView.ViewHolder item = mPendingAdditions.get(i); + item.itemView.setAlpha(1); + dispatchAddFinished(item); + mPendingAdditions.remove(i); + } + count = mPendingChanges.size(); + for (int i = count - 1; i >= 0; i--) { + endChangeAnimationIfNecessary(mPendingChanges.get(i)); + } + mPendingChanges.clear(); + if (!isRunning()) { + return; + } + + int listCount = mMovesList.size(); + for (int i = listCount - 1; i >= 0; i--) { + ArrayList moves = mMovesList.get(i); + count = moves.size(); + for (int j = count - 1; j >= 0; j--) { + MoveInfo moveInfo = moves.get(j); + RecyclerView.ViewHolder item = moveInfo.holder; + View view = item.itemView; + view.setTranslationY(0); + view.setTranslationX(0); + dispatchMoveFinished(moveInfo.holder); + moves.remove(j); + if (moves.isEmpty()) { + mMovesList.remove(moves); + } + } + } + listCount = mAdditionsList.size(); + for (int i = listCount - 1; i >= 0; i--) { + ArrayList additions = mAdditionsList.get(i); + count = additions.size(); + for (int j = count - 1; j >= 0; j--) { + RecyclerView.ViewHolder item = additions.get(j); + View view = item.itemView; + view.setAlpha(1); + dispatchAddFinished(item); + additions.remove(j); + if (additions.isEmpty()) { + mAdditionsList.remove(additions); + } + } + } + listCount = mChangesList.size(); + for (int i = listCount - 1; i >= 0; i--) { + ArrayList changes = mChangesList.get(i); + count = changes.size(); + for (int j = count - 1; j >= 0; j--) { + endChangeAnimationIfNecessary(changes.get(j)); + if (changes.isEmpty()) { + mChangesList.remove(changes); + } + } + } + + cancelAll(mRemoveAnimations); + cancelAll(mMoveAnimations); + cancelAll(mAddAnimations); + cancelAll(mChangeAnimations); + + dispatchAnimationsFinished(); + } + + private void animateRemoveImpl(final RecyclerView.ViewHolder holder) { + final View view = holder.itemView; + mRemoveAnimations.add(holder); + + Animator animator = null; + if (mSegmentItemAnimator != null) { + Segment segment = (Segment) view.getTag(R.id.me_chan_texas_item_tag); + animator = mSegmentItemAnimator.createRemoveAnimator(segment, view); + } + + if (animator == null) { + dispatchRemoveStarting(holder); + dispatchRemoveFinished(holder); + mRemoveAnimations.remove(holder); + dispatchFinishedWhenDone(); + } else { + animator.addListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchRemoveStarting(holder); + } + + @Override + public void onAnimationEnd(Animator animator) { + animator.removeListener(this); + dispatchRemoveFinished(holder); + mRemoveAnimations.remove(holder); + dispatchFinishedWhenDone(); + } + }); + animator.start(); + view.setTag(R.id.me_chan_texas_item_anim_tag, animator); + } + } + + void animateAddImpl(final RecyclerView.ViewHolder holder) { + final View view = holder.itemView; + mAddAnimations.add(holder); + + Animator animator = null; + if (mSegmentItemAnimator != null) { + Segment segment = (Segment) view.getTag(R.id.me_chan_texas_item_tag); + animator = mSegmentItemAnimator.createAddAnimator(segment, view); + } + + if (animator == null) { + dispatchAddStarting(holder); + dispatchAddFinished(holder); + mAddAnimations.remove(holder); + dispatchFinishedWhenDone(); + } else { + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchAddStarting(holder); + } + + @Override + public void onAnimationCancel(Animator animator) { + view.setAlpha(1); + } + + @Override + public void onAnimationEnd(Animator animator) { + animator.removeListener(this); + dispatchAddFinished(holder); + mAddAnimations.remove(holder); + dispatchFinishedWhenDone(); + } + }); + animator.start(); + } + } + + void animateMoveImpl(final RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { + final View view = holder.itemView; + final int deltaX = toX - fromX; + final int deltaY = toY - fromY; + + mMoveAnimations.add(holder); + Animator animator = null; + if (mSegmentItemAnimator != null) { + Segment segment = (Segment) view.getTag(R.id.me_chan_texas_item_tag); + animator = mSegmentItemAnimator.createMoveAnimator(segment, view, fromX, fromY, toX, toY); + } + + if (animator == null) { + dispatchMoveStarting(holder); + dispatchMoveFinished(holder); + mMoveAnimations.remove(holder); + dispatchFinishedWhenDone(); + } else { + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animator) { + dispatchMoveStarting(holder); + } + + @Override + public void onAnimationCancel(Animator animator) { + if (deltaX != 0) { + view.setTranslationX(0); + } + if (deltaY != 0) { + view.setTranslationY(0); + } + } + + @Override + public void onAnimationEnd(Animator animator) { + animator.removeListener(this); + dispatchMoveFinished(holder); + mMoveAnimations.remove(holder); + dispatchFinishedWhenDone(); + } + }); + animator.start(); + view.setTag(R.id.me_chan_texas_item_anim_tag, animator); + } + } + + void animateChangeImpl(final ChangeInfo changeInfo) { + final RecyclerView.ViewHolder holder = changeInfo.oldHolder; + final View view = holder == null ? null : holder.itemView; + final RecyclerView.ViewHolder newHolder = changeInfo.newHolder; + final View newView = newHolder != null ? newHolder.itemView : null; + if (view != null) { + mChangeAnimations.add(changeInfo.oldHolder); + dispatchChangeStarting(changeInfo.oldHolder, true); + view.setAlpha(1); + view.setTranslationX(0); + view.setTranslationY(0); + dispatchChangeFinished(changeInfo.oldHolder, true); + mChangeAnimations.remove(changeInfo.oldHolder); + dispatchFinishedWhenDone(); + } + if (newView != null) { + mChangeAnimations.add(changeInfo.newHolder); + dispatchChangeStarting(changeInfo.newHolder, false); + newView.setAlpha(1); + newView.setTranslationX(0); + newView.setTranslationY(0); + dispatchChangeFinished(changeInfo.newHolder, false); + mChangeAnimations.remove(changeInfo.newHolder); + dispatchFinishedWhenDone(); + } + } + + private void endChangeAnimation(List infoList, RecyclerView.ViewHolder item) { + for (int i = infoList.size() - 1; i >= 0; i--) { + ChangeInfo changeInfo = infoList.get(i); + if (endChangeAnimationIfNecessary(changeInfo, item)) { + if (changeInfo.oldHolder == null && changeInfo.newHolder == null) { + infoList.remove(changeInfo); + } + } + } + } + + private void endChangeAnimationIfNecessary(ChangeInfo changeInfo) { + if (changeInfo.oldHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.oldHolder); + } + if (changeInfo.newHolder != null) { + endChangeAnimationIfNecessary(changeInfo, changeInfo.newHolder); + } + } + + private boolean endChangeAnimationIfNecessary(ChangeInfo changeInfo, RecyclerView.ViewHolder item) { + boolean oldItem = false; + if (changeInfo.newHolder == item) { + changeInfo.newHolder = null; + } else if (changeInfo.oldHolder == item) { + changeInfo.oldHolder = null; + oldItem = true; + } else { + return false; + } + item.itemView.setAlpha(1); + item.itemView.setTranslationX(0); + item.itemView.setTranslationY(0); + dispatchChangeFinished(item, oldItem); + return true; + } + + private void resetAnimation(RecyclerView.ViewHolder holder) { + if (sDefaultInterpolator == null) { + sDefaultInterpolator = new ValueAnimator().getInterpolator(); + } + endAnimation(holder); + } + + /** + * Check the state of currently pending and running animations. If there are none + * pending/running, call {@link #dispatchAnimationsFinished()} to notify any + * listeners. + */ + void dispatchFinishedWhenDone() { + if (!isRunning()) { + dispatchAnimationsFinished(); + } + } + + void cancelAll(List viewHolders) { + for (int i = viewHolders.size() - 1; i >= 0; i--) { + View view = viewHolders.get(i).itemView; + Animator animator = (Animator) view.getTag(R.id.me_chan_texas_item_anim_tag); + if (animator != null) { + animator.cancel(); + view.setTag(R.id.me_chan_texas_item_anim_tag, null); + } + } + } + + /** + * {@inheritDoc} + *

+ * If the payload list is not empty, DefaultItemAnimator returns true. + * When this is the case: + *

    + *
  • If you override {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)}, both + * ViewHolder arguments will be the same instance. + *
  • + *
  • + * If you are not overriding {@link #animateChange(RecyclerView.ViewHolder, RecyclerView.ViewHolder, int, int, int, int)}, + * then DefaultItemAnimator will call {@link #animateMove(RecyclerView.ViewHolder, int, int, int, int)} and + * run a move animation instead. + *
  • + *
+ */ + @Override + public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, + @NonNull List payloads) { + return !payloads.isEmpty() || super.canReuseUpdatedViewHolder(viewHolder, payloads); + } +} diff --git a/library/src/main/java/me/chan/texas/renderer/ui/rv/SegmentItemDecoration.java b/library/src/main/java/me/chan/texas/renderer/ui/rv/SegmentItemDecoration.java index 9525f12d..2f2527bf 100644 --- a/library/src/main/java/me/chan/texas/renderer/ui/rv/SegmentItemDecoration.java +++ b/library/src/main/java/me/chan/texas/renderer/ui/rv/SegmentItemDecoration.java @@ -9,8 +9,10 @@ import me.chan.texas.utils.TexasUtils; import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; import androidx.recyclerview.widget.RecyclerView; +@RestrictTo(RestrictTo.Scope.LIBRARY) public class SegmentItemDecoration extends RecyclerView.ItemDecoration { private final TexasRendererAdapter mAdapter; private final me.chan.texas.misc.Rect mRect = new me.chan.texas.misc.Rect(); diff --git a/library/src/main/java/me/chan/texas/renderer/ui/rv/TexasRecyclerViewImpl.java b/library/src/main/java/me/chan/texas/renderer/ui/rv/TexasRecyclerViewImpl.java index 04d0a141..55d3c678 100644 --- a/library/src/main/java/me/chan/texas/renderer/ui/rv/TexasRecyclerViewImpl.java +++ b/library/src/main/java/me/chan/texas/renderer/ui/rv/TexasRecyclerViewImpl.java @@ -8,19 +8,17 @@ import androidx.annotation.NonNull; import androidx.annotation.RestrictTo; import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.SimpleItemAnimator; import static androidx.annotation.RestrictTo.Scope.LIBRARY; -import me.chan.texas.R; import me.chan.texas.misc.Rect; +import me.chan.texas.renderer.TexasView; import me.chan.texas.renderer.TouchEvent; import me.chan.texas.renderer.ui.TexasRendererAdapter; import me.chan.texas.renderer.ui.text.ParagraphView; import me.chan.texas.text.Document; import me.chan.texas.text.Paragraph; import me.chan.texas.text.Segment; -import me.chan.texas.renderer.selection.SelectionProvider; import me.chan.texas.text.ViewSegment; import me.chan.texas.text.layout.Layout; @@ -31,6 +29,7 @@ public class TexasRecyclerViewImpl extends RecyclerView implements TexasRecycler private OnClickedListener mOnClickedListener; private ScrollAction mScrollAction; private final TexasLinearLayoutManagerImpl mTexasLinearLayoutManager; + private final DefaultItemAnimator mItemAnimator = new DefaultItemAnimator(); public TexasRecyclerViewImpl(@NonNull Context context, TexasLinearLayoutManagerImpl texasLinearLayoutManager) { super(context); @@ -48,12 +47,7 @@ protected void onClicked(MotionEvent event) { } }; - ItemAnimator itemAnimator = getItemAnimator(); - if (itemAnimator instanceof SimpleItemAnimator) { - SimpleItemAnimator simpleItemAnimator = (SimpleItemAnimator) itemAnimator; - simpleItemAnimator.setSupportsChangeAnimations(false); - simpleItemAnimator.setChangeDuration(0); - } + setItemAnimator(mItemAnimator); } public void scrollToPosition(int position, boolean smooth, int offset) { @@ -86,6 +80,10 @@ public void getChildLocations(View child, Rect locations) { locations.bottom = child.getBottom(); } + public void setSegmentAnimator(TexasView.SegmentAnimator segmentAnimator) { + mItemAnimator.setSegmentItemAnimator(segmentAnimator); + } + private class ScrollAction implements Runnable { public int position; public int offset; diff --git a/library/src/main/java/me/chan/texas/text/Paragraph.java b/library/src/main/java/me/chan/texas/text/Paragraph.java index c4ef0e92..d89c89e9 100644 --- a/library/src/main/java/me/chan/texas/text/Paragraph.java +++ b/library/src/main/java/me/chan/texas/text/Paragraph.java @@ -24,6 +24,7 @@ import me.chan.texas.renderer.selection.Selection; import me.chan.texas.renderer.ui.RendererHost; import me.chan.texas.renderer.ui.decor.ParagraphDecor; +import me.chan.texas.text.layout.Box; import me.chan.texas.text.layout.Element; import me.chan.texas.text.layout.Glue; import me.chan.texas.text.layout.Layout; @@ -36,24 +37,12 @@ import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; +import java.util.function.Predicate; /** * 段落 */ public final class Paragraph implements Segment { - - @NonNull - @RestrictTo(LIBRARY) - volatile Layout mLayout; - - @RestrictTo(LIBRARY) - final List mElements; - - @RestrictTo(RestrictTo.Scope.LIBRARY) - SparseArrayCompat mTagsKv; - - ParagraphDecor mDecor; - /** * 默认 */ @@ -71,6 +60,29 @@ public final class Paragraph implements Segment { */ public static final int TYPESET_POLICY_ACCEPT_CONTROL_CHAR = 4; + + @NonNull + @RestrictTo(LIBRARY) + volatile Layout mLayout; + + @RestrictTo(LIBRARY) + final List mElements; + + @RestrictTo(RestrictTo.Scope.LIBRARY) + SparseArrayCompat mTagsKv; + + ParagraphDecor mDecor; + + int mId; + + private ParagraphSelection mSelection; + + private ParagraphSelection mHighlight; + + private RecyclerView.ViewHolder mHolder; + + private RendererHost mHost; + @RestrictTo(LIBRARY) @Nullable public ParagraphDecor getDecor() { @@ -83,8 +95,6 @@ public ParagraphDecor getDecor() { public @interface TypesetPolicy { } - int mId; - @Nullable @Override public Object getTag() { @@ -116,10 +126,6 @@ public void setPadding(Rect rect) { mLayout.setPadding(rect); } - private ParagraphSelection mSelection; - - private ParagraphSelection mHighlight; - @RestrictTo(LIBRARY) @Nullable public ParagraphSelection getSelection(Selection.Type type) { @@ -150,6 +156,8 @@ private Paragraph(Object tag) { } Texas.MemoryOption memoryOption = Texas.getMemoryOption(); mElements = new ArrayList<>(memoryOption.getParagraphElementInitialCapacity()); + mLayout = Layout.obtain(); + mId = Segment.nextId(); } @RestrictTo(LIBRARY) @@ -192,9 +200,6 @@ public int getId() { return mId; } - private RecyclerView.ViewHolder mHolder; - private RendererHost mHost; - @Override public void bind(RendererHost host) { mHost = host; @@ -841,6 +846,55 @@ public void setTag(@IdRes int id, @Nullable Object tag) { mTagsKv.put(id, tag); } + @NonNull + public List split(Predicate predicate) { + List paragraphs = new ArrayList<>(); + int start = 0; + int end = 1; + for (; end < mElements.size(); ++end) { + Element element = mElements.get(end); + if (!(element instanceof Box)) { + continue; + } + + Box box = (Box) element; + if (predicate.test(box)) { + paragraphs.add(fork(start, end + 1)); + start = end + 1; + } + } + + if (start < end) { + paragraphs.add(fork(start, end)); + } + + return paragraphs; + } + + private Paragraph fork(int start, int end) { + Paragraph copy = new Paragraph(null); + for (int i = start; i < end; ++i) { + copy.mElements.add(mElements.get(i)); + } + copy.mTagsKv = mTagsKv; + copy.mDecor = mDecor; + copy.mSelection = mSelection; + copy.mHighlight = mHighlight; + copy.fillTail(); + + return copy; + } + + private void fillTail() { + int size = mElements.size(); + if (size >= 2 && mElements.get(size - 2) == Glue.TERMINAL && mElements.get(size - 1) == Penalty.FORCE_BREAK) { + return; + } + + mElements.add(Glue.TERMINAL); + mElements.add(Penalty.FORCE_BREAK); + } + @NonNull @Override public String toString() { diff --git a/library/src/main/java/me/chan/texas/text/ParagraphBuilderInternal.java b/library/src/main/java/me/chan/texas/text/ParagraphBuilderInternal.java index bcf9a0f4..1b3a35f7 100644 --- a/library/src/main/java/me/chan/texas/text/ParagraphBuilderInternal.java +++ b/library/src/main/java/me/chan/texas/text/ParagraphBuilderInternal.java @@ -116,7 +116,6 @@ public Paragraph build(boolean brk) { } mParagraph.setTag(mTag); - mParagraph.mId = Segment.nextId(); return mParagraph; } @@ -137,7 +136,6 @@ public void reset(TexasOption texasOption) { mRenderOption = texasOption.getRenderOption(); mHyphenation = texasOption.getHyphenation(); mParagraph = Paragraph.obtain(); - mParagraph.mLayout = Layout.obtain(); mParagraph.mLayout.getAdvise().copy(mRenderOption); mCommonGlue = Glue.obtain(); mStretchOnlyGlue = Glue.obtain(Glue.FLAG_STRETCH); diff --git a/library/src/main/res/values/ids.xml b/library/src/main/res/values/ids.xml index 9ee8ed70..45384ecd 100644 --- a/library/src/main/res/values/ids.xml +++ b/library/src/main/res/values/ids.xml @@ -1,6 +1,7 @@ + \ No newline at end of file diff --git a/library/src/test/java/me/chan/texas/text/ParagraphUnitTest.java b/library/src/test/java/me/chan/texas/text/ParagraphUnitTest.java index 04aa0dd1..2a21f5fa 100644 --- a/library/src/test/java/me/chan/texas/text/ParagraphUnitTest.java +++ b/library/src/test/java/me/chan/texas/text/ParagraphUnitTest.java @@ -1516,4 +1516,79 @@ protected void onVisitBox(Box box, RectF inner, RectF outer, @NonNull RendererCo }; visitor.visit(paragraph); } + + @Test + public void testSpilt() { + TexasOption texasOption = new TexasOption(mPaintSet, Hyphenation.getInstance(), mMeasurer, mTextAttribute, new RenderOption()); + Paragraph.Builder builder = Paragraph.Builder.newBuilder(texasOption); + + // 重点:Paragraph 的 element 末尾一定是 Glue.TERMINAL + Penalty.FORCE_BREAK + // split 后的每个子 paragraph 也必须满足此约束(由 copy -> fillTail 保证) + + // case 1: 无匹配时,整个 paragraph 保留,末尾应有 Glue.TERMINAL + Penalty.FORCE_BREAK + builder.text("hello world"); + Paragraph paragraph = builder.build(); + java.util.List result = paragraph.split(box -> false); + Assert.assertEquals(1, result.size()); + assertElementEndsWithTerminalAndForceBreak(result.get(0)); + + // case 2: 在中间 split,两个子 paragraph 都应以 Glue.TERMINAL + Penalty.FORCE_BREAK 结尾 + builder = Paragraph.Builder.newBuilder(texasOption); + builder.text("hello world foo"); + paragraph = builder.build(); + result = paragraph.split(box -> "world".equals(box.toString())); + Assert.assertEquals(2, result.size()); + assertElementEndsWithTerminalAndForceBreak(result.get(0)); + assertElementEndsWithTerminalAndForceBreak(result.get(1)); + + // case 3: 在第一个可匹配的 box 处 split(split 从 index 1 开始遍历,故首元素不会被当作 split 点) + builder = Paragraph.Builder.newBuilder(texasOption); + builder.text("a bb ccc"); + paragraph = builder.build(); + result = paragraph.split(box -> "bb".equals(box.toString())); + Assert.assertEquals(2, result.size()); + assertElementEndsWithTerminalAndForceBreak(result.get(0)); + assertElementEndsWithTerminalAndForceBreak(result.get(1)); + + // case 4: 在最后一个 box 处 split,第二个子 paragraph 仅含最后一个 box + tail + builder = Paragraph.Builder.newBuilder(texasOption); + builder.text("x y z"); + paragraph = builder.build(); + result = paragraph.split(box -> "z".equals(box.toString())); + Assert.assertEquals(2, result.size()); + assertElementEndsWithTerminalAndForceBreak(result.get(0)); + assertElementEndsWithTerminalAndForceBreak(result.get(1)); + + // case 5: 多处 split + builder = Paragraph.Builder.newBuilder(texasOption); + builder.text("a b c d"); + paragraph = builder.build(); + result = paragraph.split(box -> "b".equals(box.toString()) || "d".equals(box.toString())); + Assert.assertEquals(3, result.size()); + for (Paragraph p : result) { + assertElementEndsWithTerminalAndForceBreak(p); + } + + builder = Paragraph.Builder.newBuilder(texasOption); + paragraph = builder.build(); + Assert.assertFalse(paragraph.hasContent()); + result = paragraph.split(b -> false); + Assert.assertEquals(1, result.size()); + assertElementEndsWithTerminalAndForceBreak(result.get(0)); + } + + /** + * 断言 Paragraph 的 element 末尾一定是 Glue.TERMINAL + Penalty.FORCE_BREAK + */ + private static void assertElementEndsWithTerminalAndForceBreak(Paragraph paragraph) { + int count = paragraph.getElementCount(); + Assert.assertTrue("element 至少需要 2 个(Glue.TERMINAL + Penalty.FORCE_BREAK)", count >= 2); + Assert.assertSame("倒数第二个 element 必须是 Glue.TERMINAL", Glue.TERMINAL, paragraph.getElement(count - 2)); + Assert.assertSame("最后一个 element 必须是 Penalty.FORCE_BREAK", Penalty.FORCE_BREAK, paragraph.getElement(count - 1)); + + if (count >= 4) { + Assert.assertNotSame("没有重复的 Glue.TERMINAL", Glue.TERMINAL, paragraph.getElement(count - 4)); + Assert.assertNotSame("没有重复的 Penalty.FORCE_BREAK", Penalty.FORCE_BREAK, paragraph.getElement(count - 3)); + } + } }