diff --git a/res/layout/conversation_item_received.xml b/res/layout/conversation_item_received.xml index b9adf27b26..0baa3f2d32 100644 --- a/res/layout/conversation_item_received.xml +++ b/res/layout/conversation_item_received.xml @@ -23,6 +23,17 @@ android:clipToPadding="false" android:clipChildren="false"> + + - - + - + + - + 8dp 1dp + 20dp 36dp 10dp diff --git a/src/org/thoughtcrime/securesms/components/AudioView.java b/src/org/thoughtcrime/securesms/components/AudioView.java index 80d9730248..e6f6b79eb7 100644 --- a/src/org/thoughtcrime/securesms/components/AudioView.java +++ b/src/org/thoughtcrime/securesms/components/AudioView.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.PorterDuff; +import android.graphics.Rect; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; @@ -212,6 +213,10 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN); } + public void getSeekBarGlobalVisibleRect(@NonNull Rect rect) { + seekBar.getGlobalVisibleRect(rect); + } + private double getProgress() { if (this.seekBar.getProgress() <= 0 || this.seekBar.getMax() <= 0) { return 0; diff --git a/src/org/thoughtcrime/securesms/components/InputPanel.java b/src/org/thoughtcrime/securesms/components/InputPanel.java index 3fc35bd84b..546f430748 100644 --- a/src/org/thoughtcrime/securesms/components/InputPanel.java +++ b/src/org/thoughtcrime/securesms/components/InputPanel.java @@ -196,6 +196,10 @@ public class InputPanel extends LinearLayout this.linkPreview.setCorners(cornerRadius, cornerRadius); } + public void clickOnComposeInput() { + composeText.performClick(); + } + public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) { this.mediaKeyboard.attach(mediaKeyboard); } diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 7713c3982d..760dd46b58 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -2697,6 +2697,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity messageRecord.getBody(), slideDeck); } + + inputPanel.clickOnComposeInput(); } @Override diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 50afa0a8d3..0ef94dfe49 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -183,6 +183,12 @@ public class ConversationFragment extends Fragment typingView = (ConversationTypingView) inflater.inflate(R.layout.conversation_typing_view, container, false); + new ConversationItemSwipeCallback( + messageRecord -> actionMode == null && + canReplyToMessage(isActionMessage(messageRecord), messageRecord), + this::handleReplyMessage + ).attachToRecyclerView(list); + return view; } @@ -359,10 +365,7 @@ public class ConversationFragment extends Fragment } for (MessageRecord messageRecord : messageRecords) { - if (messageRecord.isGroupAction() || messageRecord.isCallLog() || - messageRecord.isJoined() || messageRecord.isExpirationTimerUpdate() || - messageRecord.isEndSession() || messageRecord.isIdentityUpdate() || - messageRecord.isIdentityVerified() || messageRecord.isIdentityDefault()) + if (isActionMessage(messageRecord)) { actionMessage = true; } @@ -394,14 +397,29 @@ public class ConversationFragment extends Fragment menu.findItem(R.id.menu_context_forward).setVisible(!actionMessage && !sharedContact); menu.findItem(R.id.menu_context_details).setVisible(!actionMessage); - menu.findItem(R.id.menu_context_reply).setVisible(!actionMessage && - !messageRecord.isPending() && - !messageRecord.isFailed() && - messageRecord.isSecure()); + menu.findItem(R.id.menu_context_reply).setVisible(canReplyToMessage(actionMessage, messageRecord)); } menu.findItem(R.id.menu_context_copy).setVisible(!actionMessage && hasText); } + private static boolean canReplyToMessage(boolean actionMessage, MessageRecord messageRecord) { + return !actionMessage && + !messageRecord.isPending() && + !messageRecord.isFailed() && + messageRecord.isSecure(); + } + + private static boolean isActionMessage(MessageRecord messageRecord) { + return messageRecord.isGroupAction() || + messageRecord.isCallLog() || + messageRecord.isJoined() || + messageRecord.isExpirationTimerUpdate() || + messageRecord.isEndSession() || + messageRecord.isIdentityUpdate() || + messageRecord.isIdentityVerified() || + messageRecord.isIdentityDefault(); + } + private ConversationAdapter getListAdapter() { return (ConversationAdapter) list.getAdapter(); } diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java index 5ed32ff8f8..d19ff55eb0 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -24,6 +24,7 @@ import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.PorterDuff; +import android.graphics.Rect; import android.graphics.Typeface; import android.net.Uri; import androidx.annotation.DimenRes; @@ -135,24 +136,27 @@ public class ConversationItem extends LinearLayout implements BindableConversati private static final int MAX_MEASURE_CALLS = 3; private static final int MAX_BODY_DISPLAY_LENGTH = 1000; + private static final Rect SWIPE_RECT = new Rect(); + private MessageRecord messageRecord; private Locale locale; private boolean groupThread; private LiveRecipient recipient; private GlideRequests glideRequests; - protected ViewGroup bodyBubble; - private QuoteView quoteView; - private EmojiTextView bodyText; - private ConversationItemFooter footer; - private ConversationItemFooter stickerFooter; - private TextView groupSender; - private TextView groupSenderProfileName; - private View groupSenderHolder; - private AvatarImageView contactPhoto; - private ViewGroup contactPhotoHolder; - private AlertView alertView; - private ViewGroup container; + protected ConversationItemBodyBubble bodyBubble; + protected View reply; + protected ViewGroup contactPhotoHolder; + private QuoteView quoteView; + private EmojiTextView bodyText; + private ConversationItemFooter footer; + private ConversationItemFooter stickerFooter; + private TextView groupSender; + private TextView groupSenderProfileName; + private View groupSenderHolder; + private AvatarImageView contactPhoto; + private AlertView alertView; + private ViewGroup container; private @NonNull Set batchSelected = new HashSet<>(); private @NonNull Outliner outliner = new Outliner(); @@ -218,6 +222,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati this.groupSenderHolder = findViewById(R.id.group_sender_holder); this.quoteView = findViewById(R.id.quote_view); this.container = findViewById(R.id.container); + this.reply = findViewById(R.id.reply_icon); setOnClickListener(new ClickListener(null)); @@ -268,11 +273,24 @@ public class ConversationItem extends LinearLayout implements BindableConversati setFooter(messageRecord, nextMessageRecord, locale, groupThread); } + @Override + protected void onDetachedFromWindow() { + ConversationSwipeAnimationHelper.update(this, 0f, 1f); + super.onDetachedFromWindow(); + } + @Override public void setEventListener(@Nullable EventListener eventListener) { this.eventListener = eventListener; } + public boolean disallowSwipe(float downX, float downY) { + if (!hasAudio(messageRecord)) return false; + + audioViewStub.get().getSeekBarGlobalVisibleRect(SWIPE_RECT); + return SWIPE_RECT.contains((int) downX, (int) downY); + } + @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); @@ -313,16 +331,6 @@ public class ConversationItem extends LinearLayout implements BindableConversati } } - @Override - protected void dispatchDraw(Canvas canvas) { - super.dispatchDraw(canvas); - - if (!messageRecord.isOutgoing() && isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord)) { - outliner.setColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_text_secondary_color)); - outliner.draw(canvas, bodyBubble.getTop() + getPaddingTop(), bodyBubble.getRight(), bodyBubble.getBottom() + getPaddingTop(), bodyBubble.getLeft()); - } - } - @Override public void onRecipientChanged(@NonNull Recipient modified) { setBubbleState(messageRecord); @@ -382,6 +390,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati footer.setIconColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_secondary_color)); } + outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_sent_text_secondary_color)); + bodyBubble.setOutliner(shouldDrawBodyBubbleOutline(messageRecord) ? outliner : null); + if (audioViewStub.resolved()) { setAudioViewTint(messageRecord, this.conversationRecipient.get()); } @@ -429,6 +440,10 @@ public class ConversationItem extends LinearLayout implements BindableConversati } } + private boolean shouldDrawBodyBubbleOutline(MessageRecord messageRecord) { + return !messageRecord.isOutgoing() && isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord); + } + private boolean isCaptionlessMms(MessageRecord messageRecord) { return TextUtils.isEmpty(messageRecord.getDisplayBody(getContext())) && messageRecord.isMms() && ((MmsMessageRecord) messageRecord).getSlideDeck().getTextSlide() == null; } @@ -939,7 +954,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati } private void setGroupAuthorColor(@NonNull MessageRecord messageRecord) { - if (!messageRecord.isOutgoing() && isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord)) { + if (shouldDrawBodyBubbleOutline(messageRecord)) { groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color)); groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color)); } else if (hasSticker(messageRecord)) { diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java b/src/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java new file mode 100644 index 0000000000..8743b62a18 --- /dev/null +++ b/src/org/thoughtcrime/securesms/conversation/ConversationItemBodyBubble.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.components.Outliner; + +public class ConversationItemBodyBubble extends LinearLayout { + + private @Nullable Outliner outliner; + + public ConversationItemBodyBubble(Context context) { + super(context); + } + + public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setOutliner(@Nullable Outliner outliner) { + this.outliner = outliner; + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + if (outliner == null) return; + + outliner.draw(canvas, 0, getMeasuredWidth(), getMeasuredHeight(), 0); + } +} + diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java b/src/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java new file mode 100644 index 0000000000..a01368108f --- /dev/null +++ b/src/org/thoughtcrime/securesms/conversation/ConversationItemSwipeCallback.java @@ -0,0 +1,178 @@ +package org.thoughtcrime.securesms.conversation; + +import android.content.Context; +import android.graphics.Canvas; +import android.os.Vibrator; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.util.ServiceUtil; + +class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback { + + private static float SWIPE_SUCCESS_PROGRESS = ConversationSwipeAnimationHelper.PROGRESS_TRIGGER_POINT; + private static long SWIPE_SUCCESS_VIBE_TIME_MS = 10; + + private boolean swipeBack; + private boolean shouldTriggerSwipeFeedback = true; + private float latestDownX; + private float latestDownY; + + private final SwipeAvailabilityProvider swipeAvailabilityProvider; + private final ConversationItemTouchListener itemTouchListener; + private final OnSwipeListener onSwipeListener; + + ConversationItemSwipeCallback(@NonNull SwipeAvailabilityProvider swipeAvailabilityProvider, + @NonNull OnSwipeListener onSwipeListener) + { + super(0, ItemTouchHelper.END); + this.itemTouchListener = new ConversationItemTouchListener(this::updateLatestDownCoordinate); + this.swipeAvailabilityProvider = swipeAvailabilityProvider; + this.onSwipeListener = onSwipeListener; + } + + void attachToRecyclerView(@NonNull RecyclerView recyclerView) { + recyclerView.addOnItemTouchListener(itemTouchListener); + new ItemTouchHelper(this).attachToRecyclerView(recyclerView); + } + + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + @NonNull RecyclerView.ViewHolder target) + { + return false; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + } + + @Override + public int getSwipeDirs(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder) + { + if (cannotSwipeViewHolder(viewHolder)) return 0; + return super.getSwipeDirs(recyclerView, viewHolder); + } + + @Override + public int convertToAbsoluteDirection(int flags, int layoutDirection) { + if (swipeBack) { + swipeBack = false; + return 0; + } + return super.convertToAbsoluteDirection(flags, layoutDirection); + } + + @Override + public void onChildDraw( + @NonNull Canvas c, + @NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float dX, float dY, int actionState, boolean isCurrentlyActive) + { + if (cannotSwipeViewHolder(viewHolder)) return; + + float sign = getSignFromDirection(viewHolder.itemView); + boolean isCorrectSwipeDir = sameSign(dX, sign); + + float progress = Math.abs(dX) / (float) viewHolder.itemView.getWidth(); + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && isCorrectSwipeDir) { + ConversationSwipeAnimationHelper.update((ConversationItem) viewHolder.itemView, progress, sign); + handleSwipeFeedback((ConversationItem) viewHolder.itemView, progress); + setTouchListener(recyclerView, viewHolder, progress); + } + + if (progress == 0) shouldTriggerSwipeFeedback = true; + } + + private void handleSwipeFeedback(@NonNull ConversationItem item, float progress) { + if (progress > SWIPE_SUCCESS_PROGRESS && shouldTriggerSwipeFeedback) { + vibrate(item.getContext()); + ConversationSwipeAnimationHelper.trigger(item); + shouldTriggerSwipeFeedback = false; + } + } + + private void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder) { + if (cannotSwipeViewHolder(viewHolder)) return; + + ConversationItem item = ((ConversationItem) viewHolder.itemView); + MessageRecord messageRecord = item.getMessageRecord(); + + onSwipeListener.onSwipe(messageRecord); + } + + private void setTouchListener(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float progress) + { + recyclerView.setOnTouchListener((v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + shouldTriggerSwipeFeedback = true; + break; + case MotionEvent.ACTION_UP: + handleTouchActionUp(recyclerView, viewHolder, progress); + case MotionEvent.ACTION_CANCEL: + swipeBack = true; + shouldTriggerSwipeFeedback = false; + break; + } + return false; + }); + } + + private void handleTouchActionUp(@NonNull RecyclerView recyclerView, + @NonNull RecyclerView.ViewHolder viewHolder, + float progress) + { + if (progress > SWIPE_SUCCESS_PROGRESS) { + onSwiped(viewHolder); + if (shouldTriggerSwipeFeedback) { + vibrate(viewHolder.itemView.getContext()); + } + recyclerView.setOnTouchListener(null); + } + } + + private boolean cannotSwipeViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) { + if (!(viewHolder.itemView instanceof ConversationItem)) return true; + + ConversationItem item = ((ConversationItem) viewHolder.itemView); + return !swipeAvailabilityProvider.isSwipeAvailable(item.getMessageRecord()) || + item.disallowSwipe(latestDownX, latestDownY); + } + + private void updateLatestDownCoordinate(float x, float y) { + latestDownX = x; + latestDownY = y; + } + + private static float getSignFromDirection(@NonNull View view) { + return view.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL ? -1f : 1f; + } + + private static boolean sameSign(float dX, float sign) { + return dX * sign > 0; + } + + private static void vibrate(@NonNull Context context) { + Vibrator vibrator = ServiceUtil.getVibrator(context); + if (vibrator != null) vibrator.vibrate(SWIPE_SUCCESS_VIBE_TIME_MS); + } + + interface SwipeAvailabilityProvider { + boolean isSwipeAvailable(MessageRecord messageRecord); + } + + interface OnSwipeListener { + void onSwipe(MessageRecord messageRecord); + } +} diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationItemTouchListener.java b/src/org/thoughtcrime/securesms/conversation/ConversationItemTouchListener.java new file mode 100644 index 0000000000..4420ddb676 --- /dev/null +++ b/src/org/thoughtcrime/securesms/conversation/ConversationItemTouchListener.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.conversation; + +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +final class ConversationItemTouchListener extends RecyclerView.SimpleOnItemTouchListener { + + private final Callback callback; + + ConversationItemTouchListener(Callback callback) { + this.callback = callback; + } + + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { + if (e.getAction() == MotionEvent.ACTION_DOWN) { + callback.onDownEvent(e.getRawX(), e.getRawY()); + } + return false; + } + + interface Callback { + void onDownEvent(float rawX, float rawY); + } +} diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java b/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java new file mode 100644 index 0000000000..7fa56be156 --- /dev/null +++ b/src/org/thoughtcrime/securesms/conversation/ConversationSwipeAnimationHelper.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.conversation; + +import android.animation.ValueAnimator; +import android.content.res.Resources; +import android.view.View; +import android.view.animation.Interpolator; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.util.Util; + +final class ConversationSwipeAnimationHelper { + + public static final float PROGRESS_TRIGGER_POINT = 0.375f; + + private static final float PROGRESS_SCALE_FACTOR = 2.0f; + private static final float SCALED_PROGRESS_TRIGGER_POINT = PROGRESS_TRIGGER_POINT * PROGRESS_SCALE_FACTOR; + private static final float REPLY_SCALE_OVERSHOOT = 1.8f; + private static final float REPLY_SCALE_MAX = 1.2f; + private static final float REPLY_SCALE_MIN = 1f; + private static final long REPLY_SCALE_OVERSHOOT_DURATION = 200; + + private static final Interpolator BUBBLE_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(48)); + private static final Interpolator REPLY_ALPHA_INTERPOLATOR = new ClampingLinearInterpolator(0f, 1f, 1f / SCALED_PROGRESS_TRIGGER_POINT); + private static final Interpolator REPLY_TRANSITION_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(10)); + private static final Interpolator AVATAR_INTERPOLATOR = new ClampingLinearInterpolator(0f, dpToPx(8)); + private static final Interpolator REPLY_SCALE_INTERPOLATOR = new ClampingLinearInterpolator(REPLY_SCALE_MIN, REPLY_SCALE_MAX); + + private ConversationSwipeAnimationHelper() { + } + + public static void update(@NonNull ConversationItem conversationItem, float progress, float sign) { + float scaledProgress = Math.min(1f, progress * PROGRESS_SCALE_FACTOR); + updateBodyBubbleTransition(conversationItem.bodyBubble, scaledProgress, sign); + updateReplyIconTransition(conversationItem.reply, scaledProgress, sign); + updateContactPhotoHolderTransition(conversationItem.contactPhotoHolder, scaledProgress, sign); + } + + public static void trigger(@NonNull ConversationItem conversationItem) { + triggerReplyIcon(conversationItem.reply); + } + + private static void updateBodyBubbleTransition(@NonNull View bodyBubble, float progress, float sign) { + bodyBubble.setTranslationX(BUBBLE_INTERPOLATOR.getInterpolation(progress) * sign); + } + + private static void updateReplyIconTransition(@NonNull View replyIcon, float progress, float sign) { + if (progress > 0.05f) { + replyIcon.setAlpha(REPLY_ALPHA_INTERPOLATOR.getInterpolation(progress)); + } else replyIcon.setAlpha(0f); + + replyIcon.setTranslationX(REPLY_TRANSITION_INTERPOLATOR.getInterpolation(progress) * sign); + + if (progress < SCALED_PROGRESS_TRIGGER_POINT) { + float scale = REPLY_SCALE_INTERPOLATOR.getInterpolation(progress); + replyIcon.setScaleX(scale); + replyIcon.setScaleY(scale); + } + } + + private static void updateContactPhotoHolderTransition(@Nullable View contactPhotoHolder, + float progress, + float sign) + { + if (contactPhotoHolder == null) return; + contactPhotoHolder.setTranslationX(AVATAR_INTERPOLATOR.getInterpolation(progress) * sign); + } + + private static void triggerReplyIcon(@NonNull View replyIcon) { + ValueAnimator animator = ValueAnimator.ofFloat(REPLY_SCALE_MAX, REPLY_SCALE_OVERSHOOT, REPLY_SCALE_MAX); + animator.setDuration(REPLY_SCALE_OVERSHOOT_DURATION); + animator.addUpdateListener(animation -> { + replyIcon.setScaleX((float) animation.getAnimatedValue()); + replyIcon.setScaleY((float) animation.getAnimatedValue()); + }); + animator.start(); + } + + private static int dpToPx(int dp) { + return (int) (dp * Resources.getSystem().getDisplayMetrics().density); + } + + private static final class ClampingLinearInterpolator implements Interpolator { + + private final float slope; + private final float yIntercept; + private final float max; + private final float min; + + ClampingLinearInterpolator(float start, float end) { + this(start, end, 1.0f); + } + + ClampingLinearInterpolator(float start, float end, float scale) { + slope = (end - start) * scale; + yIntercept = start; + max = Math.max(start, end); + min = Math.min(start, end); + } + + @Override + public float getInterpolation(float input) { + return Util.clamp(slope * input + yIntercept, min, max); + } + } + +}