Add Swipe-To-Reply in conversations.

Swipe-To-Reply is a progress based animation that is controlled
by the ConversationItemSwipeCallback.  We use the TouchListener to
keep track of the latest down coordinates, so that we can check whether
we are over a seek bar. The SwipeAnimationHelper is responsible for
actually performing the transitions as the swipe progresses.
This commit is contained in:
Alex Hart 2019-09-05 10:18:54 -03:00 committed by Greyson Parrelli
parent 582028f2c2
commit 007ea43dc8
12 changed files with 456 additions and 35 deletions

View File

@ -23,6 +23,17 @@
android:clipToPadding="false"
android:clipChildren="false">
<ImageView
android:id="@+id/reply_icon"
android:layout_width="@dimen/conversation_item_reply_size"
android:layout_height="@dimen/conversation_item_reply_size"
android:src="@drawable/ic_reply_white_24dp"
android:tint="?compose_icon_tint"
android:alpha="0"
android:layout_alignTop="@id/body_bubble"
android:layout_alignBottom="@id/body_bubble"
android:layout_alignStart="@id/body_bubble" />
<FrameLayout
android:id="@+id/contact_photo_container"
android:layout_width="36dp"
@ -40,7 +51,7 @@
</FrameLayout>
<LinearLayout
<org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble
android:id="@+id/body_bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -206,7 +217,7 @@
app:footer_text_color="?conversation_sticker_footer_text_color"
app:footer_icon_color="?conversation_sticker_footer_icon_color"/>
</LinearLayout>
</org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble>
<org.thoughtcrime.securesms.components.AlertView
android:id="@+id/indicators_parent"

View File

@ -22,7 +22,18 @@
android:clipToPadding="false"
android:clipChildren="false">
<LinearLayout
<ImageView
android:id="@+id/reply_icon"
android:layout_width="@dimen/conversation_item_reply_size"
android:layout_height="@dimen/conversation_item_reply_size"
android:alpha="0"
android:src="@drawable/ic_reply_white_24dp"
android:tint="?compose_icon_tint"
android:layout_alignTop="@id/body_bubble"
android:layout_alignBottom="@id/body_bubble"
android:layout_alignStart="@id/body_bubble" />
<org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble
android:id="@+id/body_bubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@ -164,7 +175,7 @@
app:footer_text_color="?conversation_sticker_footer_text_color"
app:footer_icon_color="?conversation_sticker_footer_icon_color"/>
</LinearLayout>
</org.thoughtcrime.securesms.conversation.ConversationItemBodyBubble>
<org.thoughtcrime.securesms.components.AlertView
android:id="@+id/indicators_parent"

View File

@ -64,6 +64,7 @@
<dimen name="conversation_vertical_message_spacing_default">8dp</dimen>
<dimen name="conversation_vertical_message_spacing_collapse">1dp</dimen>
<dimen name="conversation_item_reply_size">20dp</dimen>
<dimen name="conversation_item_avatar_size">36dp</dimen>
<dimen name="quote_corner_radius_large">10dp</dimen>

View File

@ -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;

View File

@ -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);
}

View File

@ -2697,6 +2697,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
messageRecord.getBody(),
slideDeck);
}
inputPanel.clickOnComposeInput();
}
@Override

View File

@ -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();
}

View File

@ -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<MessageRecord> 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)) {

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}
}