Skip to content

Instantly share code, notes, and snippets.

@vivchar
Last active March 15, 2018 08:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vivchar/d8ad1bc2fc4a93a623402513f6028181 to your computer and use it in GitHub Desktop.
Save vivchar/d8ad1bc2fc4a93a623402513f6028181 to your computer and use it in GitHub Desktop.
Tooltips for Android
public class Tooltip {
public static final String TAG = Tooltip.class.getSimpleName();
@NonNull
private final Context mContext;
@NonNull
private final CharSequence mText;
private final int mOffset;
private final boolean mCancelable;
private final boolean mOutsideCancelable;
private final boolean mAnchorCancelable;
private final boolean mClipping;
@NonNull
private final View mContentView;
@NonNull
private final View mAnchorView;
@NonNull
private final PopupWindow mPopupWindow;
@NonNull
private final PopupWindow mHelperPopupWindow;
@NonNull
private final View mHelperContentView;
@NonNull
private PointF mCurrentLocation = new PointF();
@NonNull
private final ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener = this::updateLocation;
@NonNull
private final View.OnAttachStateChangeListener mOnViewDetachListener = (OnViewDetachListener) this::dismiss;
@NonNull
private final OnTouchUpListener mOnTouchUpListener = () -> dismiss();
public Tooltip(@NonNull final Context context,
@NonNull final View anchorView,
@NonNull final CharSequence text,
final int offset,
final boolean cancelable,
final boolean outsideCancelable,
final boolean clipping,
final boolean anchorCancelable) {
mContext = context;
mText = text;
mOffset = offset;
mCancelable = cancelable;
mOutsideCancelable = outsideCancelable;
mAnchorCancelable = anchorCancelable;
mClipping = clipping;
mContentView = createContentView(mContext, mText, mCancelable, mOffset);
mAnchorView = prepareAnchorView(anchorView);
mPopupWindow = createPopupWindow(mContext, mContentView, mCancelable, mOutsideCancelable);
mHelperContentView = createHelperView(mContext);
mHelperPopupWindow = createHelperPopupWindow(mContext, mHelperContentView);
}
@NonNull
private View createHelperView(final @NonNull Context context) {
final View view = new View(context);
view.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener);
return view;
}
@NonNull
private PopupWindow createHelperPopupWindow(@NonNull final Context context, @NonNull final View contentView) {
final PopupWindow popupWindow = new PopupWindow(context);
popupWindow.setContentView(contentView);
popupWindow.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
popupWindow.setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED);
popupWindow.setWidth(0);
popupWindow.setHeight(WindowManager.LayoutParams.MATCH_PARENT);
return popupWindow;
}
@NonNull
private View prepareAnchorView(@NonNull final View anchorView) {
anchorView.addOnAttachStateChangeListener(mOnViewDetachListener);
if (mAnchorCancelable) {
anchorView.setOnTouchListener(mOnTouchUpListener);
}
return anchorView;
}
@NonNull
private PopupWindow createPopupWindow(@NonNull final Context context,
@NonNull final View contentView,
final boolean cancelable,
final boolean outsideCancelable) {
final PopupWindow popupWindow = new PopupWindow(context);
popupWindow.setClippingEnabled(false);
popupWindow.setFocusable(false);
popupWindow.setOutsideTouchable(outsideCancelable);
popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
popupWindow.setContentView(contentView);
popupWindow.setWindowLayoutMode(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
popupWindow.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
popupWindow.setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED);
return popupWindow;
}
@NonNull
private View createContentView(@NonNull final Context context,
@NonNull final CharSequence text,
final boolean cancelable,
final int offset) {
final TextView textView = new TextView(context);
textView.setBackgroundResource(R.drawable.pop_up_b_l); /* set any background to get a correct size */
textView.getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener); /* to listen when content will measure */
textView.setText(text);
textView.setTextColor(Color.WHITE);
textView.setTextSize(13);
if (cancelable) {
textView.setOnClickListener(v -> dismiss());
}
return textView;
}
private void updateLocation() {
debug("layout changed, calculating new position.");
final PointF location = calculateLocation();
if (!location.equals(mCurrentLocation)) {
debug("updateLocation");
mPopupWindow.setClippingEnabled(mClipping);
mPopupWindow.update((int) location.x, (int) location.y, mPopupWindow.getWidth(), mPopupWindow.getHeight());
}
mCurrentLocation = location;
}
@NonNull
private PointF calculateLocation() {
final PointF location = new PointF();
final RectF anchorRect = calculateRectOnScreen(mAnchorView);
final RectF contentRect = calculateRectOnScreen(mContentView);
final RectF screenRect = getScreenRect();
final boolean leftBias = anchorRect.centerX() < screenRect.centerX();
final boolean rightBias = !leftBias;
final boolean topBias = anchorRect.centerY() < screenRect.centerY();
final boolean bottomBias = !topBias;
if (leftBias && topBias) {
mContentView.setBackgroundResource(R.drawable.pop_up_u_l);
location.x = (int) anchorRect.left;
location.y = (int) anchorRect.top + anchorRect.height() + mOffset;
} else if (leftBias && bottomBias) {
mContentView.setBackgroundResource(R.drawable.pop_up_b_l);
location.x = (int) anchorRect.left;
location.y = (int) anchorRect.top - contentRect.height() - mOffset;
} else if (rightBias && topBias) {
mContentView.setBackgroundResource(R.drawable.pop_up_u_r);
location.x = (int) (anchorRect.left - (contentRect.width() - anchorRect.width()));
location.y = (int) anchorRect.bottom + mOffset;
} else if (rightBias && bottomBias) {
mContentView.setBackgroundResource(R.drawable.pop_up_b_r);
location.x = (int) (anchorRect.left - (contentRect.width() - anchorRect.width()));
location.y = (int) anchorRect.top - contentRect.height() - mOffset;
}
debug("leftBias && topBias: " + leftBias + " && " + topBias);
debug("anchorRect: " + anchorRect + ", width: " + anchorRect.width() + ", height: " + anchorRect.height());
debug("contentRect: " + contentRect + ", width: " + contentRect.width() + ", height: " + contentRect.height());
debug("screenRect: " + screenRect + ", width: " + screenRect.width() + ", height: " + screenRect.height());
debug("location: " + location);
return location;
}
public boolean isShowing() {
return mPopupWindow.isShowing();
}
public void show() {
if (!isShowing()) {
debug("show");
mHelperPopupWindow.showAtLocation(mAnchorView, Gravity.NO_GRAVITY, 0, 0);
if (mClipping) {
mAnchorView.post(() -> mPopupWindow.showAsDropDown(mAnchorView));
} else {
mAnchorView.post(() -> mPopupWindow.showAtLocation(mAnchorView, Gravity.NO_GRAVITY, 0, 0));
}
}
}
public void dismiss() {
debug("dismiss");
mAnchorView.setOnTouchListener(null);
mAnchorView.removeOnAttachStateChangeListener(mOnViewDetachListener);
removeOnGlobalLayoutListener(mContentView, mOnGlobalLayoutListener);
removeOnGlobalLayoutListener(mHelperContentView , mOnGlobalLayoutListener);
mPopupWindow.dismiss();
mHelperPopupWindow.dismiss();
}
private interface OnTouchUpListener extends View.OnTouchListener {
@Override
default boolean onTouch(final View v, final MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_UP) {
onTouchUp();
}
return false;
}
void onTouchUp();
}
private interface OnViewDetachListener extends View.OnAttachStateChangeListener {
@Override
default void onViewAttachedToWindow(final View v) {}
@Override
default void onViewDetachedFromWindow(final View v) {
onViewDetached();
}
void onViewDetached();
}
private void debug(@NonNull final String message) {
PalLog.d(TAG, message);
}
@NonNull
public RectF getScreenRect() {
final DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics();
return new RectF(0, 0, metrics.widthPixels, metrics.heightPixels);
}
public static class Builder {
@NonNull
private final Context mContext;
@NonNull
private final View mAnchor;
private CharSequence mText = "";
private boolean mCancelable = true;
private boolean mOutsideCancelable = false;
private int mOffset = dpToPx(-6);
private boolean mClipping = false;
private boolean mAnchorCancelable = true;
public Builder(@NonNull final View anchorView) {
mAnchor = anchorView;
mContext = anchorView.getContext();
}
@NonNull
public Builder setOutsideCancelable(final boolean outsideCancelable) {
mOutsideCancelable = outsideCancelable;
return this;
}
@NonNull
public Builder setCancelable(final boolean cancelable) {
mCancelable = cancelable;
return this;
}
@NonNull
public Builder setAnchorCancelable(final boolean anchorCancelable) {
mAnchorCancelable = anchorCancelable;
return this;
}
@NonNull
public Builder setClipping(final boolean clipping) {
mClipping = clipping;
return this;
}
@NonNull
public Builder setOffset(final int offset) {
mOffset = offset;
return this;
}
@NonNull
public Builder setText(@NonNull final CharSequence text) {
mText = text;
return this;
}
@NonNull
public Builder setText(@StringRes final int text) {
setText(mContext.getString(text));
return this;
}
@NonNull
public Tooltip build() {
return new Tooltip(mContext, mAnchor, mText, mOffset, mCancelable, mOutsideCancelable, mClipping, mAnchorCancelable);
}
}
protected static class Utils {
@SuppressWarnings("deprecation")
@SuppressLint("ObsoleteSdkInt")
public static void removeOnGlobalLayoutListener(@NonNull final View view,
@NonNull final ViewTreeObserver.OnGlobalLayoutListener listener) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
view.getViewTreeObserver().removeOnGlobalLayoutListener(listener);
} else {
view.getViewTreeObserver().removeGlobalOnLayoutListener(listener);
}
}
public static int dpToPx(final int dp) {
return (int) (dp * Resources.getSystem().getDisplayMetrics().density);
}
@NonNull
public static RectF calculateRectOnScreen(@NonNull final View view) {
final int[] location = new int[2];
view.getLocationOnScreen(location);
return new RectF(location[0], location[1], location[0] + view.getMeasuredWidth(), location[1] + view.getMeasuredHeight());
}
@NonNull
public static RectF calculateRectInWindow(@NonNull final View view) {
final int[] location = new int[2];
view.getLocationInWindow(location);
return new RectF(location[0], location[1], location[0] + view.getMeasuredWidth(), location[1] + view.getMeasuredHeight());
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment