BottomSheet animation duration - android

I am using the BottomSheet view for one of my layouts. For a very specific case, I need the animation duration of the BottomSheet which slides in the layout via the setState(...) method. It should be something around 400ms but I would prefer not to have a "magic" number for that case.

The duration of BottomSheetBehavior animation triggered by setState or touch events depends on various conditions.
BottomSheetBehavior internally uses ViewDragHelper which determines animation duration time inside computeAxisDuration() private method.
This duration depends on some arbitrary values and cannot change it by official API:
public class ViewDragHelper {
...
private static final int BASE_SETTLE_DURATION = 256; // ms
private static final int MAX_SETTLE_DURATION = 6000; // ms
...
}
The hack solution to change show/hide animation time is...
Copy whole BottomSheetBehavior and ViewDragHelper classes souce code to your project (with different package name)
In your file BottomSheetBehavior.java - change import ViewDragHelper to use your ViewDragHelper instead of original Android API class.
In file ViewDragHelper.java - change duration returned by computeAxisDuration to satisfy your requirements, e.g. return exactly 400 ms.
Use your BottomSheetBehavior class instead of original one.
It is very bad solution but it works in my project. Details below.
File ViewDragHelper.java
public class ViewDragHelper {
// Additional fields and methods defined.
private int mSettleDuration = BASE_SETTLE_DURATION;
public void setDurationSpeedFactor(final float factor) {
mSettleDuration = (int)(factor * BASE_SETTLE_DURATION);
}
private boolean mSkipAnimation = false;
public void setSkipAnimation(final boolean skipAnimation) {
this.mSkipAnimation = skipAnimation;
}
// Modified version of private function.
private int computeAxisDuration(int delta, int velocity, int motionRange) {
if (delta == 0) {
return 0;
}
final int width = mParentView.getWidth();
final int halfWidth = width / 2;
final float distanceRatio = Math.min(1f, (float) Math.abs(delta) / width);
final float distance = halfWidth + halfWidth
* distanceInfluenceForSnapDuration(distanceRatio);
int duration;
velocity = Math.abs(velocity);
if (velocity > 0) {
duration = 4 * Math.round(1000 * Math.abs(distance / velocity));
} else {
final float range = (float) Math.abs(delta) / motionRange;
duration = (int) ((range + 1) * mSettleDuration);
}
return Math.min(duration, MAX_SETTLE_DURATION);
}
// Rest of original class body here...
}
File BottomSheetBehavior.java
// Comment out original ViewDragHelper and use above modified version.
//import androidx.customview.widget.ViewDragHelper;
import your.custom.ViewDragHelper;
public class BottomSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
/**
* Allows to set up ViewDragHelper immediately or when it is created.
*/
public interface IDragHelperConfig {
void onDragHelperCreated(ViewDragHelper viewDragHelper);
}
private IDragHelperConfig iDragHelperConfig;
public void setIDragHelperConfig(IDragHelperConfig iDragHelperConfig) {
this.iDragHelperConfig = iDragHelperConfig;
if (viewDragHelper != null) {
iDragHelperConfig.onDragHelperCreated(viewDragHelper);
}
}
//...
public boolean onLayoutChild(...) {
if (viewDragHelper == null) {
viewDragHelper = ViewDragHelper.create(parent, dragCallback);
if (iDragHelperConfig != null) {
iDragHelperConfig.onDragHelperCreated(viewDragHelper);
}
}
}
//...
}
Sample code inside any android View initialization
val params: CoordinatorLayout.LayoutParams = myLayout.layoutParams as CoordinatorLayout.LayoutParams
val behavior = BottomSheetBehavior<FrameLayout>(myLayout.context, null)
// Optionally, set the height of the bottom sheet when it is collapsed (usefull for experiments).
// behavior.peekHeight = 20
// Configure bottom sheet behavior.
behavior.setIDragHelperConfig {
// Set how many times to slow the speed of the animation
// (relative to original Android animation speed).
it.setDurationSpeedFactor(20f)
// Optionally, turn off animation
// Useful when entering a new activity and
// initial animation is not desired.
it.setSkipAnimation(false)
}
params.behavior = behavior

Yes you can add animations in bottom sheet.
h
// init the bottom sheet behavior
BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(mBottomSheet);
// change the state of the bottom sheet
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
// set callback for changes
bottomSheetBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
#Override
public void onStateChanged(#NonNull View bottomSheet, int newState) {
// Called every time when the bottom sheet changes its state.
}
#Override
public void onSlide(#NonNull View bottomSheet, float slideOffset) {
if (isAdded()) {
animateBottomSheetArrows(slideOffset);
}
}
});
}
private void animateBottomSheetArrows(float slideOffset) {
// Animate counter-clockwise
mLeftArrow.setRotation(slideOffset * -180);
// Animate clockwise
mRightArrow.setRotation(slideOffset * 180);
}

Related

Android LayoutParams not being updated properly

So I have a scroll view, and I need to adjust the height of my scroll view to make sure it stays above a modal pop-up view. I can't use a constraint layout because this modal pop-up view is not a child of the same view parent. So I'm trying to dynamically update my scroll views layout params so its height is small enough to not get hidden behind the modal pop-up.
The pop-up view height can change at points so I have a callback that returns the new height of the modal view anytime it changes. In that callback I adjust the scroll views height like so:
someModalView.onHeightChanged = { newViewHeight ->
Log.d("TESTHEIGHT", "PreHeight = ${scrollView.height}")
scrollView.layoutParams = FrameLayout.LayoutParams(scrollView.width, scrollView.height - newViewHeight)
scrollView.requestLayout()
Log.d("TESTHEIGHT", "PostHeight = ${scrollView.height}")
}
Unfortunately the above code seems to do nothing and in my logs I can see that the PreHeight prints the same height as the PostHeight. Any reason the views height isn't getting changed?
Also, I did debug it and make sure that newViewHeight is not 0, and it isn't, it's ~800
Ended up making it work by adding padding to the view rather than changing its height like so:
someModalView.onHeightChanged = { newViewHeight ->
scrollView.setPadding(0, 0, 0, newViewHeight)
}
This works exactly how i needed it to, however it doesn't really answer the question so I will just leave it in the answer for anyone else who it might help. But it would still be nice to know why changing the layout params wouldn't update the views height.
try to see it works for you
val params = scrollView.layoutParams;
params.height = scrollView.height - newViewHeight
scrollView.layoutParams = params
Once I needed to get the height of the softKeyboard to update my view:
public class MainActivity extends AppCompatActivity {
private ScrollView sView;
private int heightDiff;
#Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
sView = findViewById(R.id.scrollView);
//Here we get the height of soft keyboard by observing changes of softKeyboard height.
sView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
public void onGlobalLayout() {
heightDiff = sView.getRootView().getHeight() - sView.getHeight();
}
});
final EditText email = findViewById(R.id.eemail);
EditText firstName = findViewById(R.id.efirstname);
firstName.setOnFocusChangeListener(new View.OnFocusChangeListener() {
#Override
public void onFocusChange(View v, boolean hasFocus) {
if (!isVisibleWhileSoftKeyboardShowing(email) && hasFocus) {
sView.postDelayed(new Runnable() {
#Override
public void run() {
sView.smoothScrollBy(0, 200);
}
}, 500);
}
}
});
}
/**
* check if a view is currently visible in the screen or not
*
* #param view
* #return
*/
public boolean isVisibleWhileSoftKeyboardShowing(final View view) {
if (view == null) {
return false;
}
if (!view.isShown()) {
return false;
}
final Rect actualPosition = new Rect();
view.getGlobalVisibleRect(actualPosition);
final Rect screen = new Rect(0, 0, getScreenWidth(), getScreenHeight() - heightDiff);
return actualPosition.intersect(screen);
}
/**
* to get screen width
*
* #return
*/
public static int getScreenWidth() {
return Resources.getSystem().getDisplayMetrics().widthPixels;
}
/**
* to get screen height
*
* #return
*/
public static int getScreenHeight() {
return Resources.getSystem().getDisplayMetrics().heightPixels;
}
}
heightDiff is the height of softKeyboard. There was 2 edit texts. I wanted to scroll if softKeyboard hided the lower one. Hope this is similar to your case.

How to display popup instead of CAB when textview is selected?

I am making a reading app and it has a full screen activity.
When user selects the part of the text a contextual action bar appears with option of copy. This is the default behaviour. But this actionbar blocks the text under it so user cannot select it.
I want to show a popup window like below.
I tried returning false from onCreateActionMode but when i do this i can't select the text either.
I want to know if there is a standart way to achieve this as many reading applications use this design.
I don't know how Play Books achieves this, but you could create a PopupWindow and calculate where to position it based on the selected text using Layout.getSelectionPath and a little bit of math. Basically, we're going to:
Calculate the bounds of the selected text
Calculate the bounds and initial location of the PopupWindow
Calculate the difference between the two
Offset the PopupWindow to rest center horizontally/vertically above or below the selected text
Calculating the selection bounds
From the docs:
Fills in the specified Path with a representation of a highlight
between the specified offsets. This will often be a rectangle or a
potentially discontinuous set of rectangles. If the start and end are
the same, the returned path is empty.
So, the specified offsets in our case would be the start and end of the selection, which can be found using Selection.getSelectionStart and Selection.getSelectionEnd. For convenience, TextView gives us TextView.getSelectionStart, TextView.getSelectionEnd and TextView.getLayout.
final Path selDest = new Path();
final RectF selBounds = new RectF();
final Rect outBounds = new Rect();
// Calculate the selection start and end offset
final int selStart = yourTextView.getSelectionStart();
final int selEnd = yourTextView.getSelectionEnd();
final int min = Math.max(0, Math.min(selStart, selEnd));
final int max = Math.max(0, Math.max(selStart, selEnd));
// Calculate the selection outBounds
yourTextView.getLayout().getSelectionPath(min, max, selDest);
selDest.computeBounds(selBounds, true /* this param is ignored */);
selBounds.roundOut(outBounds);
Now that we have a Rect of the selected text bounds, we can choose where we want to place the PopupWindow relative to it. In this case, we'll center it horizontally along the top or bottom of the selected text, depending on how much space we have to display our popup.
Calculating the initial popup coordinates
Next we'll need to calculate the bounds of the popup content. To do this, we'll first need to call PopupWindow.showAtLocation, but the bounds of the View we inflate won't immediately be available, so I'd recommend using a ViewTreeObserver.OnGlobalLayoutListener to wait for them to become available.
popupWindow.showAtLocation(yourTextView, Gravity.TOP, 0, 0)
PopupWindow.showAtLocation requires:
A View to retrieve a valid Window token from, which just uniquely identifies the Window to place the popup in
An optional gravity, but in our case it'll be Gravity.TOP
Optional x/y offsets
Since we can't determine the x/y offset until the popup content is laid out, we'll just initially place it at the default location. If you try to call PopupWindow.showAtLocation before the View you pass in has been laid out, you'll receive a WindowManager.BadTokenException, so you may consider using a ViewTreeObserver.OnGlobalLayoutListener to avoid that, but it mostly comes up when you have text selected and rotate your device.
final Rect cframe = new Rect();
final int[] cloc = new int[2];
popupContent.getLocationOnScreen(cloc);
popupContent.getLocalVisibleRect(cbounds);
popupContent.getWindowVisibleDisplayFrame(cframe);
final int scrollY = ((View) yourTextView.getParent()).getScrollY();
final int[] tloc = new int[2];
yourTextView.getLocationInWindow(tloc);
final int startX = cloc[0] + cbounds.centerX();
final int startY = cloc[1] + cbounds.centerY() - (tloc[1] - cframe.top) - scrollY;
View.getLocationOnScreen will return us the x/y coordinates for the popup content.
View.getLocalVisibleRect will return us the bounds of the popup content
View.getWindowVisibleDisplayFrame will return us the offsets to accommodate for the action bar, if present
View.getScrollY will return us the y offset for whatever scroll container our TextView is in (ScrollView in my case)
View.getLocationInWindow will return us the y offset for our TextView, in case the action bar pushes it down a little
Once we've gotten all of the info we need, we can calculate the final starting x/y of the popup content and then use this to figure out the difference between them and the selected text Rect so we can PopupWindow.update to the new location.
Calculating the offset popup coordinates
// Calculate the top and bottom offset of the popup relative to the selection bounds
final int popupHeight = cbounds.height();
final int textPadding = yourTextView.getPaddingLeft();
final int topOffset = Math.round(selBounds.top - startY);
final int btmOffset = Math.round(selBounds.bottom - (startY - popupHeight));
// Calculate the x/y coordinates for the popup relative to the selection bounds
final int x = Math.round(selBounds.centerX() + textPadding - startX);
final int y = Math.round(selBounds.top - scrollY < startY ? btmOffset : topOffset);
If there's enough room to display the popup above the selected text, we'll put it there; otherwise, we'll offset it below the selected text. In my case, I have 16dp padding around my TextView, so that needs to be taken into account too. We'll end up with the final x and y location to offset the PopupWindow with.
popupWindow.update(x, y, -1, -1);
-1 here just represents the default width/height for we supplied for the PopupWindow, in our case it'll be ViewGroup.LayoutParams.WRAP_CONTENT
Listening for selection changes
We want the PopupWindow to update every time we change the selected text.
An easy way to listen for selection changes is to subclass TextView and provide a callback to TextView.onSelectionChanged.
public class NotifyingSelectionTextView extends AppCompatTextView {
private SelectionChangeListener listener;
public NotifyingSelectionTextView(Context context, #Nullable AttributeSet attrs) {
super(context, attrs);
}
#Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
if (listener != null) {
if (hasSelection()) {
listener.onTextSelected();
} else {
listener.onTextUnselected();
}
}
}
public void setSelectionChangeListener(SelectionChangeListener listener) {
this.listener = listener;
}
public interface SelectionChangeListener {
void onTextSelected();
void onTextUnselected();
}
}
Listening for scroll changes
If you have a TextView in a scroll container like ScrollView, you may also want to listen for scroll changes so that you can anchor your popup while you're scrolling. An easy way to listen for those is to subclass ScrollView and provide a callback to View.onScrollChanged
public class NotifyingScrollView extends ScrollView {
private ScrollChangeListener listener;
public NotifyingScrollView(Context context, #Nullable AttributeSet attrs) {
super(context, attrs);
}
#Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
if (listener != null) {
listener.onScrollChanged();
}
}
public void setScrollChangeListener(ScrollChangeListener listener) {
this.listener = listener;
}
public interface ScrollChangeListener {
void onScrollChanged();
}
}
Creating an empty ActionMode.Callback
Like you mention in your post, we'll need to return true in ActionMode.Callback.onCreateActionMode in order for our text to remain selectable. But we'll also need to call Menu.clear in ActionMode.Callback.onPrepareActionMode in order to remove all the items you may find in an ActionMode for selected text.
/** An {#link ActionMode.Callback} used to remove all action items from text selection */
static final class EmptyActionMode extends SimpleActionModeCallback {
#Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
// Return true to ensure the text is still selectable
return true;
}
#Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
// Remove all action items to provide an actionmode-less selection
menu.clear();
return true;
}
}
Now we can use TextView.setCustomSelectionActionModeCallback to apply our custom ActionMode. SimpleActionModeCallback is a custom class that just provides stubs for ActionMode.Callback, kinda similar to ViewPager.SimpleOnPageChangeListener
public class SimpleActionModeCallback implements ActionMode.Callback {
#Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
return false;
}
#Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
#Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
return false;
}
#Override
public void onDestroyActionMode(ActionMode mode) {
}
}
Layouts
This is the Activity layout we're using:
<your.package.name.NotifyingScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="#+id/notifying_scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<your.package.name.NotifyingSelectionTextView
android:id="#+id/notifying_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:textIsSelectable="true"
android:textSize="20sp" />
</your.package.name.NotifyingScrollView>
This is our popup layout:
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#drawable/action_mode_popup_bg"
android:orientation="vertical"
tools:ignore="ContentDescription">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageButton
android:id="#+id/view_action_mode_popup_add_note"
style="#style/ActionModePopupButton"
android:src="#drawable/ic_note_add_black_24dp" />
<ImageButton
android:id="#+id/view_action_mode_popup_translate"
style="#style/ActionModePopupButton"
android:src="#drawable/ic_translate_black_24dp" />
<ImageButton
android:id="#+id/view_action_mode_popup_search"
style="#style/ActionModePopupButton"
android:src="#drawable/ic_search_black_24dp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_margin="8dp"
android:background="#android:color/darker_gray" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageButton
android:id="#+id/view_action_mode_popup_red"
style="#style/ActionModePopupSwatch"
android:src="#drawable/round_red" />
<ImageButton
android:id="#+id/view_action_mode_popup_yellow"
style="#style/ActionModePopupSwatch"
android:src="#drawable/round_yellow" />
<ImageButton
android:id="#+id/view_action_mode_popup_green"
style="#style/ActionModePopupSwatch"
android:src="#drawable/round_green" />
<ImageButton
android:id="#+id/view_action_mode_popup_blue"
style="#style/ActionModePopupSwatch"
android:src="#drawable/round_blue" />
<ImageButton
android:id="#+id/view_action_mode_popup_clear_format"
style="#style/ActionModePopupSwatch"
android:src="#drawable/ic_format_clear_black_24dp"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
These are our popup button styles:
<style name="ActionModePopupButton">
<item name="android:layout_width">48dp</item>
<item name="android:layout_height">48dp</item>
<item name="android:layout_weight">1</item>
<item name="android:background">?selectableItemBackground</item>
</style>
<style name="ActionModePopupSwatch" parent="ActionModePopupButton">
<item name="android:padding">12dp</item>
</style>
Util
The ViewUtils.onGlobalLayout you'll see is just a util method for handling some ViewTreeObserver.OnGlobalLayoutListener boilerplate.
public static void onGlobalLayout(final View view, final Runnable runnable) {
final OnGlobalLayoutListener listener = new OnGlobalLayoutListener() {
#Override
public void onGlobalLayout() {
view.getViewTreeObserver().removeOnGlobalLayoutListener(this);
runnable.run();
}
};
view.getViewTreeObserver().addOnGlobalLayoutListener(listener);
}
Bringing it altogether
So, now that we've:
Calculated the selected text bounds
Calculated the popup bounds
Calculated the difference and determined the popup offsets
Provided a way to listen for scroll changes and selection changes
Created our Activity and popup layouts
Bringing everything together may look something like:
public class ActionModePopupActivity extends AppCompatActivity
implements ScrollChangeListener, SelectionChangeListener {
private static final int DEFAULT_WIDTH = -1;
private static final int DEFAULT_HEIGHT = -1;
private final Point currLoc = new Point();
private final Point startLoc = new Point();
private final Rect cbounds = new Rect();
private final PopupWindow popupWindow = new PopupWindow();
private final ActionMode.Callback emptyActionMode = new EmptyActionMode();
private NotifyingSelectionTextView yourTextView;
#SuppressLint("InflateParams")
#Override
protected void onCreate(#Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_action_mode_popup);
// Initialize the popup content, only add it to the Window once we've selected text
final LayoutInflater inflater = LayoutInflater.from(this);
popupWindow.setContentView(inflater.inflate(R.layout.view_action_mode_popup, null));
popupWindow.setWidth(WRAP_CONTENT);
popupWindow.setHeight(WRAP_CONTENT);
// Initialize to the NotifyingScrollView to observe scroll changes
final NotifyingScrollView scroll
= (NotifyingScrollView) findViewById(R.id.notifying_scroll_view);
scroll.setScrollChangeListener(this);
// Initialize the TextView to observe selection changes and provide an empty ActionMode
yourTextView = (NotifyingSelectionTextView) findViewById(R.id.notifying_text_view);
yourTextView.setText(IPSUM);
yourTextView.setSelectionChangeListener(this);
yourTextView.setCustomSelectionActionModeCallback(emptyActionMode);
}
#Override
public void onScrollChanged() {
// Anchor the popup while the user scrolls
if (popupWindow.isShowing()) {
final Point ploc = calculatePopupLocation();
popupWindow.update(ploc.x, ploc.y, DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
}
#Override
public void onTextSelected() {
final View popupContent = popupWindow.getContentView();
if (popupWindow.isShowing()) {
// Calculate the updated x/y pop coordinates
final Point ploc = calculatePopupLocation();
popupWindow.update(ploc.x, ploc.y, DEFAULT_WIDTH, DEFAULT_HEIGHT);
} else {
// Add the popup to the Window and position it relative to the selected text bounds
ViewUtils.onGlobalLayout(yourTextView, () -> {
popupWindow.showAtLocation(yourTextView, TOP, 0, 0);
// Wait for the popup content to be laid out
ViewUtils.onGlobalLayout(popupContent, () -> {
final Rect cframe = new Rect();
final int[] cloc = new int[2];
popupContent.getLocationOnScreen(cloc);
popupContent.getLocalVisibleRect(cbounds);
popupContent.getWindowVisibleDisplayFrame(cframe);
final int scrollY = ((View) yourTextView.getParent()).getScrollY();
final int[] tloc = new int[2];
yourTextView.getLocationInWindow(tloc);
final int startX = cloc[0] + cbounds.centerX();
final int startY = cloc[1] + cbounds.centerY() - (tloc[1] - cframe.top) - scrollY;
startLoc.set(startX, startY);
final Point ploc = calculatePopupLocation();
popupWindow.update(ploc.x, ploc.y, DEFAULT_WIDTH, DEFAULT_HEIGHT);
});
});
}
}
#Override
public void onTextUnselected() {
popupWindow.dismiss();
}
/** Used to calculate where we should position the {#link PopupWindow} */
private Point calculatePopupLocation() {
final ScrollView parent = (ScrollView) yourTextView.getParent();
// Calculate the selection start and end offset
final int selStart = yourTextView.getSelectionStart();
final int selEnd = yourTextView.getSelectionEnd();
final int min = Math.max(0, Math.min(selStart, selEnd));
final int max = Math.max(0, Math.max(selStart, selEnd));
// Calculate the selection bounds
final RectF selBounds = new RectF();
final Path selection = new Path();
yourTextView.getLayout().getSelectionPath(min, max, selection);
selection.computeBounds(selBounds, true /* this param is ignored */);
// Retrieve the center x/y of the popup content
final int cx = startLoc.x;
final int cy = startLoc.y;
// Calculate the top and bottom offset of the popup relative to the selection bounds
final int popupHeight = cbounds.height();
final int textPadding = yourTextView.getPaddingLeft();
final int topOffset = Math.round(selBounds.top - cy);
final int btmOffset = Math.round(selBounds.bottom - (cy - popupHeight));
// Calculate the x/y coordinates for the popup relative to the selection bounds
final int scrollY = parent.getScrollY();
final int x = Math.round(selBounds.centerX() + textPadding - cx);
final int y = Math.round(selBounds.top - scrollY < cy ? btmOffset : topOffset);
currLoc.set(x, y - scrollY);
return currLoc;
}
/** An {#link ActionMode.Callback} used to remove all action items from text selection */
static final class EmptyActionMode extends SimpleActionModeCallback {
#Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
// Return true to ensure the yourTextView is still selectable
return true;
}
#Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
// Remove all action items to provide an actionmode-less selection
menu.clear();
return true;
}
}
}
Results
With the action bar (link to video):
Without the action bar (link to video):
Bonus - animation
Because we know the starting location of the PopupWindow and the offset location as the selection changes, we can easily perform a linear interpolation between the two values to create a nice animation when we're moving things around.
public static float lerp(float a, float b, float v) {
return a + (b - a) * v;
}
private static final int DEFAULT_ANIM_DUR = 350;
private static final int DEFAULT_ANIM_DELAY = 500;
#Override
public void onTextSelected() {
final View popupContent = popupWindow.getContentView();
if (popupWindow.isShowing()) {
// Calculate the updated x/y pop coordinates
popupContent.getHandler().removeCallbacksAndMessages(null);
popupContent.postDelayed(() -> {
// The current x/y location of the popup
final int currx = currLoc.x;
final int curry = currLoc.y;
// Calculate the updated x/y pop coordinates
final Point ploc = calculatePopupLocation();
currLoc.set(ploc.x, ploc.y);
// Linear interpolate between the current and updated popup coordinates
final ValueAnimator anim = ValueAnimator.ofFloat(0f, 1f);
anim.addUpdateListener(animation -> {
final float v = (float) animation.getAnimatedValue();
final int x = Math.round(AnimUtils.lerp(currx, ploc.x, v));
final int y = Math.round(AnimUtils.lerp(curry, ploc.y, v));
popupWindow.update(x, y, DEFAULT_WIDTH, DEFAULT_HEIGHT);
});
anim.setDuration(DEFAULT_ANIM_DUR);
anim.start();
}, DEFAULT_ANIM_DELAY);
} else {
...
}
}
Results
With the action bar - animation (link to video)
Extra
I don't go into how to attach on click listeners to the popup actions and there are probably several ways to achieve this same effect with different calculations and implementations. But I will mention that if you wanted to retrieve the selected text and then do something with it, you'd just need to CharSequence.subSequence the min and max from the selected text.
Anyway, I hope this has been helpful! Let me know if you have any questions.

Custom circular reveal transition results in "java.lang.UnsupportedOperationException" when paused?

I created a custom circular reveal transition to use as part of an Activity's enter transition (specifically, I am setting the transition as the window's enter transition by calling Window#setEnterTransition()):
public class CircularRevealTransition extends Visibility {
private final Rect mStartBounds = new Rect();
/**
* Use the view's location as the circular reveal's starting position.
*/
public CircularRevealTransition(View v) {
int[] loc = new int[2];
v.getLocationInWindow(loc);
mStartBounds.set(loc[0], loc[1], loc[0] + v.getWidth(), loc[1] + v.getHeight());
}
#Override
public Animator onAppear(ViewGroup sceneRoot, final View v, TransitionValues startValues, TransitionValues endValues) {
if (endValues == null) {
return null;
}
int halfWidth = v.getWidth() / 2;
int halfHeight = v.getHeight() / 2;
float startX = mStartBounds.left + mStartBounds.width() / 2 - halfWidth;
float startY = mStartBounds.top + mStartBounds.height() / 2 - halfHeight;
float endX = v.getTranslationX();
float endY = v.getTranslationY();
v.setTranslationX(startX);
v.setTranslationY(startY);
// Create a circular reveal animator to play behind a shared
// element during the Activity Transition.
Animator revealAnimator = ViewAnimationUtils.createCircularReveal(v, halfWidth, halfHeight, 0f,
FloatMath.sqrt(halfWidth * halfHeight + halfHeight * halfHeight));
revealAnimator.addListener(new AnimatorListenerAdapter() {
#Override
public void onAnimationEnd(Animator animation) {
// Set the view's visibility to VISIBLE to prevent the
// reveal from "blinking" at the end of the animation.
v.setVisibility(View.VISIBLE);
}
});
// Translate the circular reveal into place as it animates.
PropertyValuesHolder pvhX = PropertyValuesHolder.ofFloat("translationX", startX, endX);
PropertyValuesHolder pvhY = PropertyValuesHolder.ofFloat("translationY", startY, endY);
Animator translationAnimator = ObjectAnimator.ofPropertyValuesHolder(v, pvhX, pvhY);
AnimatorSet anim = new AnimatorSet();
anim.setInterpolator(getInterpolator());
anim.playTogether(revealAnimator, translationAnimator);
return anim;
}
}
This works OK normally. However, when I click the "back button" in the middle of the transition, I get the following exception:
Process: com.adp.activity.transitions, PID: 13800
java.lang.UnsupportedOperationException
at android.view.RenderNodeAnimator.pause(RenderNodeAnimator.java:251)
at android.animation.AnimatorSet.pause(AnimatorSet.java:472)
at android.transition.Transition.pause(Transition.java:1671)
at android.transition.TransitionSet.pause(TransitionSet.java:483)
at android.app.ActivityTransitionState.startExitBackTransition(ActivityTransitionState.java:269)
at android.app.Activity.finishAfterTransition(Activity.java:4672)
at com.adp.activity.transitions.DetailsActivity.finishAfterTransition(DetailsActivity.java:167)
at android.app.Activity.onBackPressed(Activity.java:2480)
Is there any specific reason why I am getting this error? How should it be avoided?
You will need to create a subclass of Animator that ignores calls to pause() and resume() in order to avoid this exception.
For more details, I just finished a post about this topic below:
Part 1: http://halfthought.wordpress.com/2014/11/07/reveal-transition/
Part 2: https://halfthought.wordpress.com/2014/12/02/reveal-activity-transitions/
Is there any specific reason why I am getting this error?
ViewAnimationUtils.createCircularReveal is a shortcut for creating a new RevealAnimator, which is a subclass of RenderNodeAnimator. By default, RenderNodeAnimator.pause throws an UnsupportedOperationException. You see this occur here in your stack trace:
java.lang.UnsupportedOperationException
at android.view.RenderNodeAnimator.pause(RenderNodeAnimator.java:251)
When Activity.onBackPressed is called in Lollipop, it makes a new call to Activity.finishAfterTransition, which eventually makes a call back to Animator.pause in Transition.pause(android.view.View), which is when your UnsupportedOperationException is finally thrown.
The reason it isn't thrown when using the "back" button after the transition is complete, is due to how the EnterTransitionCoordinator handles the entering Transition once it's completed.
How should it be avoided?
I suppose you have a couple of options, but neither are really ideal:
Option 1
Attach a TransitionListener when you call Window.setEnterTransition so you can monitor when to invoke the "back" button. So, something like:
public class YourActivity extends Activity {
/** True if the current window transition is animating, false otherwise */
private boolean mIsAnimating = true;
#Override
protected void onCreate(Bundle savedInstanceState) {
// Get the Window and enable Activity transitions
final Window window = getWindow();
window.requestFeature(Window.FEATURE_CONTENT_TRANSITIONS);
// Call through to super
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_child);
// Set the window transition and attach our listener
final Transition circularReveal = new CircularRevealTransition(yourView);
window.setEnterTransition(circularReveal.addListener(new TransitionListenerAdapter() {
#Override
public void onTransitionEnd(Transition transition) {
super.onTransitionEnd(transition);
mIsAnimating = false;
}
}));
// Restore the transition state if available
if (savedInstanceState != null) {
mIsAnimating = savedInstanceState.getBoolean("key");
}
}
#Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
// Save the current transition state
outState.putBoolean("key", mIsAnimating);
}
#Override
public void onBackPressed() {
if (!mIsAnimating) {
super.onBackPressed();
}
}
}
Option 2
Use reflection to call ActivityTransitionState.clear, which will stop Transition.pause(android.view.View) from being called in ActivityTransitionState.startExitBackTransition.
#Override
public void onBackPressed() {
if (!mIsAnimating) {
super.onBackPressed();
} else {
clearTransitionState();
super.onBackPressed();
}
}
private void clearTransitionState() {
try {
// Get the ActivityTransitionState Field
final Field atsf = Activity.class.getDeclaredField("mActivityTransitionState");
atsf.setAccessible(true);
// Get the ActivityTransitionState
final Object ats = atsf.get(this);
// Invoke the ActivityTransitionState.clear Method
final Method clear = ats.getClass().getDeclaredMethod("clear", (Class[]) null);
clear.invoke(ats);
} catch (final Exception ignored) {
// Nothing to do
}
}
Obviously each has drawbacks. Option 1 basically disables the "back" button until the transition is complete. Option 2 allows you to interrupt using the "back" button, but clears the transition state and uses reflection.
Here's a gfy of the results. You can see how it completely transitions from "A" to "M" and back again, then the "back" button interrupts the transition and goes back to "A". That'll make more sense if you watch it.
At any rate, I hope that helps you out some.
You can add listener to enter transition that sets flag transitionInProgress in methods onTransitionStart() / onTransitionEnd(). Then, you can override method finishAfterTransition() and then check transitionInProgress flag, and call super only if transition finished. Otherwise you can just finish() your Activity or do nothing.
override fun finishAfterTransition() {
if (!transitionInProgress){
super.finishAfterTransition()
} else {
finish()
}
}

How To Use Multiple TouchDelegate

i have two ImageButtons, each inside a RelativeLayout and these two RelativeLayouts are in another RelativeLayout, i want to set TouchDelegate for each ImageButton. If normally i add TouchDelegate to each ImageButton and it's parent RelativeLayout then just one ImageButton works properly, Another one doesn't extend it's clicking area. So PLease help me on how to use TouchDelegate in both ImageButtons. If it's not possible then what can be a effective way to extend the clicking area of a view? Thanks in advance ........
Here is my xml code:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout android:id="#+id/FrameContainer"
android:layout_width="fill_parent" android:layout_height="fill_parent"
xmlns:android="http://schemas.android.com/apk/res/android">
<RelativeLayout android:layout_alignParentLeft="true"
android:id="#+id/relativeLayout3" android:layout_width="wrap_content"
android:layout_height="wrap_content">
<RelativeLayout android:layout_alignParentLeft="true"
android:id="#+id/relativeLayout1" android:layout_width="113dip"
android:layout_height="25dip">
<ImageButton android:id="#+id/tutorial1"
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:background="#null" android:src="#drawable/tutorial" />
</RelativeLayout>
<RelativeLayout android:layout_alignParentLeft="true"
android:id="#+id/relativeLayout2" android:layout_width="113dip"
android:layout_height="25dip" android:layout_marginLeft="100dip">
<ImageButton android:id="#+id/tutorial2"
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:background="#null" android:src="#drawable/tutorial"
android:layout_marginLeft="50dip" />
</RelativeLayout>
</RelativeLayout>
My Activity class :
import android.app.Activity;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.TouchDelegate;
import android.view.View;
import android.widget.ImageButton;
import android.widget.Toast;
public class TestTouchDelegate extends Activity {
/** Called when the activity is first created. */
#Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
View mParent1 = findViewById(R.id.relativeLayout1);
mParent1.post(new Runnable() {
#Override
public void run() {
Rect bounds1 = new Rect();
ImageButton mTutorialButton1 = (ImageButton) findViewById(R.id.tutorial1);
mTutorialButton1.setEnabled(true);
mTutorialButton1.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) {
Toast.makeText(TestTouchDelegate.this, "Test TouchDelegate 1", Toast.LENGTH_SHORT).show();
}
});
mTutorialButton1.getHitRect(bounds1);
bounds1.right += 50;
TouchDelegate touchDelegate1 = new TouchDelegate(bounds1, mTutorialButton1);
if (View.class.isInstance(mTutorialButton1.getParent())) {
((View) mTutorialButton1.getParent()).setTouchDelegate(touchDelegate1);
}
}
});
//View mParent = findViewById(R.id.FrameContainer);
View mParent2 = findViewById(R.id.relativeLayout2);
mParent2.post(new Runnable() {
#Override
public void run() {
Rect bounds2 = new Rect();
ImageButton mTutorialButton2 = (ImageButton) findViewById(R.id.tutorial2);
mTutorialButton2.setEnabled(true);
mTutorialButton2.setOnClickListener(new View.OnClickListener() {
public void onClick(View view) {
Toast.makeText(TestTouchDelegate.this, "Test TouchDelegate 2", Toast.LENGTH_SHORT).show();
}
});
mTutorialButton2.getHitRect(bounds2);
bounds2.left += 50;
TouchDelegate touchDelegate2 = new TouchDelegate(bounds2, mTutorialButton2);
if (View.class.isInstance(mTutorialButton2.getParent())) {
((View) mTutorialButton2.getParent()).setTouchDelegate(touchDelegate2);
}
}
});
}
}
You can use composite pattern to be able to add more than one TouchDelegate to the View. Steps:
Create TouchDelegateComposite (no matter what view you'll pass as an
argument, it's used just to get the Context)
Create necessary TouchDelegates and add them to composite
Add composite to view as they recommend here (via view.post(new Runnable))
public class TouchDelegateComposite extends TouchDelegate {
private final List<TouchDelegate> delegates = new ArrayList<TouchDelegate>();
private static final Rect emptyRect = new Rect();
public TouchDelegateComposite(View view) {
super(emptyRect, view);
}
public void addDelegate(TouchDelegate delegate) {
if (delegate != null) {
delegates.add(delegate);
}
}
#Override
public boolean onTouchEvent(MotionEvent event) {
boolean res = false;
float x = event.getX();
float y = event.getY();
for (TouchDelegate delegate : delegates) {
event.setLocation(x, y);
res = delegate.onTouchEvent(event) || res;
}
return res;
}
}
There is only supposed to be one touch delegate for each view. The documentation for getTouchDelegate() in the View class reads:
"Gets the TouchDelegate for this View."
There is only to be one TouchDelegate. To use only one TouchDelegate per view, you can wrap each touchable view within a view with dimensions reflecting what you would like to be touchable. An android developer at square gives an example of how you can do this for multiple Views using just one static method (http://www.youtube.com/watch?v=jF6Ad4GYjRU&t=37m4s):
public static void expandTouchArea(final View bigView, final View smallView, final int extraPadding) {
bigView.post(new Runnable() {
#Override
public void run() {
Rect rect = new Rect();
smallView.getHitRect(rect);
rect.top -= extraPadding;
rect.left -= extraPadding;
rect.right += extraPadding;
rect.bottom += extraPadding;
bigView.setTouchDelegate(new TouchDelegate(rect, smallView));
}
});
}
Let's say that you do not want to clutter your view hierarchy. There are two other options I can think of. You can define the bounds of what is touchable inside the touchable view and make sure to pass all touchevents to that child view from respective parent views. Or you can override getHitRect() for the touchable view. The former will quickly clutter your code and make it difficult to understand, so the latter is the better way forward. You want to go with overriding getHitRect.
Where mPadding is the amount of extra area you want to be touchable around your view, you could use something like the following:
#Override
public void getHitRect(Rect outRect) {
outRect.set(getLeft() - mPadding, getTop() - mPadding, getRight() + mPadding, getTop() + mPadding);
}
If you use code like the above you'll have to consider what touchable views are nearby. The touchable area of the View that is highest on the stack could overlap on top of another View.
Another similar option would be to just change the padding of the touchable view. I dislike this as a solution because it can become difficult to keep track of how Views are being resized.
Kotlin version of #need1milliondollars's answer:
class TouchDelegateComposite(view: View) : TouchDelegate(Rect(), view) {
private val delegates: MutableList<TouchDelegate> = ArrayList()
fun addDelegate(delegate: TouchDelegate) {
delegates.add(delegate)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
var res = false
val x = event.x
val y = event.y
for (delegate in delegates) {
event.setLocation(x, y)
res = delegate.onTouchEvent(event) || res
}
return res
}
}
Fully working Kotlin extension function which allows for multiple views to increase their touch target by the same amount:
// Example of usage
parentLayout.increaseHitAreaForViews(views = *arrayOf(myFirstView, mySecondView, myThirdView))
/*
* Use this function if a parent view contains more than one view that
* needs to increase its touch target hit area.
*
* Call this on the parent view
*/
fun View.increaseHitAreaForViews(#DimenRes radiusIncreaseDpRes: Int = R.dimen.touch_target_default_radius_increase, vararg views: View) {
val increasedRadiusPixels = resources.getDimensionPixelSize(radiusIncreaseDpRes)
val touchDelegateComposite = TouchDelegateComposite(this)
post {
views.forEach { view ->
val rect = Rect()
view.getHitRect(rect)
rect.top -= increasedRadiusPixels
rect.left -= increasedRadiusPixels
rect.bottom += increasedRadiusPixels
rect.right += increasedRadiusPixels
touchDelegateComposite.addDelegate(TouchDelegate(rect, view))
}
touchDelegate = touchDelegateComposite
}
}
class TouchDelegateComposite(view: View) : TouchDelegate(Rect(), view) {
private val delegates = mutableListOf<TouchDelegate>()
fun addDelegate(delegate: TouchDelegate) {
delegates.add(delegate)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
var res = false
for (delegate in delegates) {
event.setLocation(event.x, event.y)
res = delegate.onTouchEvent(event) || res
}
return res
}
}
To make your code working you need to decrease left border of bounds2, and not increase it.
bounds2.left -= 50;
After playing around with TouchDelegate, I came to the code below, which works for me all the time on any Android version. The trick is to extend area guarantied after layout is called.
public class ViewUtils {
public static final void extendTouchArea(final View view,
final int padding) {
view.getViewTreeObserver().addOnGlobalLayoutListener(
new OnGlobalLayoutListener() {
#Override
#SuppressWarnings("deprecation")
public void onGlobalLayout() {
final Rect touchArea = new Rect();
view.getHitRect(touchArea);
touchArea.top -= padding;
touchArea.bottom += padding;
touchArea.left -= padding;
touchArea.right += padding;
final TouchDelegate touchDelegate =
new TouchDelegate(touchArea, view);
final View parent = (View) view.getParent();
parent.setTouchDelegate(touchDelegate);
view.getViewTreeObserver().removeGlobalOnLayoutListener(this);
}
});
}
}
this seemed to be working for me http://cyrilmottier.com/2012/02/16/listview-tips-tricks-5-enlarged-touchable-areas/
Ok i guess nobody provides the real answer to make solution of it and make it easy.Lately i had same issue and reading all above i just had no clue how just to make it work.But finally i did it.First thing to keep in your mind!Lets pretend you have one whole layout which is holding your two small buttons which area must be expanded,so you MUST make another layout inside your main layout and put another button to your newly created layout so in that case with static method you can give touch delegate to 2 buttons at the same time.Now more deeply and step by step into code!
first you surely just find the view of your MAINLAYOUT and Button like this.(this layout will hold our first button)
RelativeLayout mymainlayout = (RelativeLayout)findViewById(R.id.mymainlayout)
Button mybutoninthislayout = (Button)findViewById(R.id.mybutton)
ok we done finding the main layout and its button view which will hold everything its just our onCreate() displaying layout but you have to find in case to use it later.Ok what next?We create another RelativeLayout inside our main layout which width and height is on your taste(this newly created layout will hold our second button)
RelativeLayout myinnerlayout = (RelativeLayout)findViewById(R.id.myinnerlayout)
Button mybuttoninsideinnerlayout = (Button)findViewById(R.id.mysecondbutton)
ok we done finding views so we can now just copy and paste the code of our guy who firstly answered your question.Just copy that code inside your main activity.
public static void expandTouchArea(final View bigView, final View smallView, final int extraPadding) {
bigView.post(new Runnable() {
#Override
public void run() {
Rect rect = new Rect();
smallView.getHitRect(rect);
rect.top -= extraPadding;
rect.left -= extraPadding;
rect.right += extraPadding;
rect.bottom += extraPadding;
bigView.setTouchDelegate(new TouchDelegate(rect, smallView));
}
});
}
Now how to use this method and make it work?here is how!
on your onCreate() method paste the next code snippet
expandTouchArea(mymainlayout,mybutoninthislayout,60);
expandTouchArea(myinnerlayout, mybuttoninsideinnerlayout,60);
Explanation on what we did in this snipped.We took our created static method named expandTouchArea() and gave 3 arguments.
1st argument-The layout which holds the button which area must be expanded
2nd argument - actual button to expand the area of it
3rd argument - the area in pixels of how much we want the button area to be expanded!
ENJOY!
I had the same issue: Trying to add multiple TouchDelegates for different LinearLayouts that route touch events to separate Switches respectively, in one layout.
For details please refer to this question asked by me and my answer.
What I found is: Once I enclose the LinearLayouts each by another LinearLayout, respectively, the second (end every other successive) TouchDelegate starts to work as expected.
So this might help the OP to create a working solution to his problem.
I don't have a satisfying explanation on why it behaves like this, though.
I copy the code of TouchDelegate and made some alter. Now it can support multi Views regardless of whether thoese views had common parents
class MyTouchDelegate: TouchDelegate {
private var mDelegateViews = ArrayList<View>()
private var mBoundses = ArrayList<Rect>()
private var mSlopBoundses = ArrayList<Rect>()
private var mDelegateTargeted: Boolean = false
val ABOVE = 1
val BELOW = 2
val TO_LEFT = 4
val TO_RIGHT = 8
private var mSlop: Int = 0
constructor(context: Context): super(Rect(), View(context)) {
mSlop = ViewConfiguration.get(context).scaledTouchSlop
}
fun addTouchDelegate(delegateView: View, bounds: Rect) {
val slopBounds = Rect(bounds)
slopBounds.inset(-mSlop, -mSlop)
mDelegateViews.add(delegateView)
mSlopBoundses.add(slopBounds)
mBoundses.add(Rect(bounds))
}
var targetIndex = -1
override fun onTouchEvent(event: MotionEvent): Boolean {
val x = event.x.toInt()
val y = event.y.toInt()
var sendToDelegate = false
var hit = true
var handled = false
when (event.action) {
MotionEvent.ACTION_DOWN -> {
targetIndex = -1
for ((index, item) in mBoundses.withIndex()) {
if (item.contains(x, y)) {
mDelegateTargeted = true
targetIndex = index
sendToDelegate = true
}
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_MOVE -> {
sendToDelegate = mDelegateTargeted
if (sendToDelegate) {
val slopBounds = mSlopBoundses[targetIndex]
if (!slopBounds.contains(x, y)) {
hit = false
}
}
}
MotionEvent.ACTION_CANCEL -> {
sendToDelegate = mDelegateTargeted
mDelegateTargeted = false
}
}
if (sendToDelegate) {
val delegateView = mDelegateViews[targetIndex]
if (hit) {
// Offset event coordinates to be inside the target view
event.setLocation((delegateView.width / 2).toFloat(), (delegateView.height / 2).toFloat())
} else {
// Offset event coordinates to be outside the target view (in case it does
// something like tracking pressed state)
val slop = mSlop
event.setLocation((-(slop * 2)).toFloat(), (-(slop * 2)).toFloat())
}
handled = delegateView.dispatchTouchEvent(event)
}
return handled
}
}
use it like this
fun expandTouchArea(viewList: List<View>, touchSlop: Int) {
val rect = Rect()
viewList.forEach {
it.getHitRect(rect)
if (rect.left == rect.right && rect.top == rect.bottom) {
postDelay(Runnable { expandTouchArea(viewList, touchSlop) }, 200)
return
}
rect.top -= touchSlop
rect.left -= touchSlop
rect.right += touchSlop
rect.bottom += touchSlop
val parent = it.parent as? View
if (parent != null) {
val parentDelegate = parent.touchDelegate
if (parentDelegate != null) {
(parentDelegate as? MyTouchDelegate)?.addTouchDelegate(it, rect)
} else {
val touchDelegate = MyTouchDelegate(this)
touchDelegate.addTouchDelegate(it, rect)
parent.touchDelegate = touchDelegate
}
}
}
}
I implemented a simple solution from the link that Brendan Weinstein listed above - the static method was incredibly clean and tidy compared to all other solutions. Padding for me simply doesnt work.
My use case was increasing the touch area of 2 small buttons to improve UX.
I have a MyUserInterfaceManager class, where i insert the static function from the youtube video;
public static void changeViewsTouchArea(final View newTouchArea,
final View viewToChange) {
newTouchArea.post(new Runnable() {
#Override
public void run() {
Rect rect = new Rect(0,0, newTouchArea.getWidth(), newTouchArea.getHeight());
newTouchArea.setTouchDelegate(new TouchDelegate(rect, viewToChange));
}
});
}
Then in XML i have the following code (per imagebutton);
<!-- constrain touch area to button / view!-->
<FrameLayout
android:id="#+id/btn_a_touch_area"
android:layout_width="#dimen/large_touch_area"
android:layout_height="#dimen/large_touch_area"
/>
<ImageButton
android:id="#+id/btn_id_here"
android:layout_width="#dimen/button_size"
android:layout_height="#dimen/button_size" />
The in the onCreateView(...) method of my fragment i called the method per View that needs the touch area modified (after binding both Views!);
MyUserInterfaceManager.changeViewsTouchArea(buttonATouchArea, buttonA);
buttonA.setOnClickListener(v -> {
// add event code here
});
This solution means i can explicitly design and see the touch area in layout files - a must have to ensure im not getting too close to other View touch areas (compared to the "calculate and add pixels" methods recommended by google and others).

What's the best way to check if the view is visible on the window?

What's the best way to check if the view is visible on the window?
I have a CustomView which is part of my SDK and anybody can add CustomView to their layouts. My CustomView is taking some actions when it is visible to the user periodically. So if view becomes invisible to the user then it needs to stop the timer and when it becomes visible again it should restart its course.
But unfortunately there is no certain way of checking if my CustomView becomes visible or invisible to the user. There are few things that I can check and listen to: onVisibilityChange //it is for view's visibility change, and is introduced in new API 8 version so has backward compatibility issue
onWindowVisibilityChange //but my CustomView can be part of a ViewFlipper's Views so it can pose issues
onDetachedFromWindows //this not as useful
onWindowFocusChanged //Again my CustomView can be part of ViewFlipper's views. So if anybody has faced this kind of issues please throw some light.
In my case the following code works the best to listen if the View is visible or not:
#Override
protected void onWindowVisibilityChanged(int visibility) {
super.onWindowVisibilityChanged(visibility);
Log.e(TAG, "is view visible?: " + (visibility == View.VISIBLE));
}
onDraw() is called each time the view needs to be drawn. When the view is off screen then onDraw() is never called. When a tiny bit of the view is becomes visible to the user then onDraw() is called. This is not ideal but I cannot see another call to use as I want to do the same thing. Remember to call the super.onDraw or the view won't get drawn. Be careful of changing anything in onDraw that causes the view to be invalidate as that will cause another call to onDraw.
If you are using a listview then getView can be used whenever your listview becomes shown to the user.
obviously the activity onPause() is called all your views are all covered up and are not visible to the user. perhaps calling invalidate() on the parent and if ondraw() is not called then it is not visible.
This is a method that I have used quite a bit in my apps and have had work out quite well for me:
static private int screenW = 0, screenH = 0;
#SuppressWarnings("deprecation") static public boolean onScreen(View view) {
int coordinates[] = { -1, -1 };
view.getLocationOnScreen(coordinates);
// Check if view is outside left or top
if (coordinates[0] + view.getWidth() < 0) return false;
if (coordinates[1] + view.getHeight() < 0) return false;
// Lazy get screen size. Only the first time.
if (screenW == 0 || screenH == 0) {
if (MyApplication.getSharedContext() == null) return false;
Display display = ((WindowManager)MyApplication.getSharedContext().getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
try {
Point screenSize = new Point();
display.getSize(screenSize); // Only available on API 13+
screenW = screenSize.x;
screenH = screenSize.y;
} catch (NoSuchMethodError e) { // The backup methods will only be used if the device is running pre-13, so it's fine that they were deprecated in API 13, thus the suppress warnings annotation at the start of the method.
screenW = display.getWidth();
screenH = display.getHeight();
}
}
// Check if view is outside right and bottom
if (coordinates[0] > screenW) return false;
if (coordinates[1] > screenH) return false;
// Else, view is (at least partially) in the screen bounds
return true;
}
To use it, just pass in any view or subclass of view (IE, just about anything that draws on screen in Android.) It'll return true if it's on screen or false if it's not... pretty intuitive, I think.
If you're not using the above method as a static, then you can probably get a context some other way, but in order to get the Application context from a static method, you need to do these two things:
1 - Add the following attribute to your application tag in your manifest:
android:name="com.package.MyApplication"
2 - Add in a class that extends Application, like so:
public class MyApplication extends Application {
// MyApplication exists solely to provide a context accessible from static methods.
private static Context context;
#Override public void onCreate() {
super.onCreate();
MyApplication.context = getApplicationContext();
}
public static Context getSharedContext() {
return MyApplication.context;
}
}
In addition to the view.getVisibility() there is view.isShown().
isShown checks the view tree to determine if all ancestors are also visible.
Although, this doesn't handle obstructed views, only views that are hidden or gone in either themselves or one of its parents.
In dealing with a similar issue, where I needed to know if the view has some other window on top of it, I used this in my custom View:
#Override
public void onWindowFocusChanged(boolean hasWindowFocus) {
super.onWindowFocusChanged(hasWindowFocus);
if (!hasWindowFocus) {
} else {
}
}
This can be checked using getGlobalVisibleRect method. If rectangle returned by this method has exactly the same size as View has, then current View is completely visible on the Screen.
/**
* Returns whether this View is completely visible on the screen
*
* #param view view to check
* #return True if this view is completely visible on the screen, or false otherwise.
*/
public static boolean onScreen(#NonNull View view) {
Rect visibleRect = new Rect();
view.getGlobalVisibleRect(visibleRect);
return visibleRect.height() == view.getHeight() && visibleRect.width() == view.getWidth();
}
If you need to calculate visibility percentage you can do it using square calculation:
float visiblePercentage = (visibleRect.height() * visibleRect.width()) / (float)(view.getHeight() * view.getWidth())
This solution takes into account view obstructed by statusbar and toolbar, also as view outside the window (e.g. scrolled out of screen)
/**
* Test, if given {#code view} is FULLY visible in window. Takes into accout window decorations
* (statusbar and toolbar)
*
* #param view
* #return true, only if the WHOLE view is visible in window
*/
public static boolean isViewFullyVisible(View view) {
if (view == null || !view.isShown())
return false;
//windowRect - will hold available area where content remain visible to users
//Takes into account screen decorations (e.g. statusbar)
Rect windowRect = new Rect();
view.getWindowVisibleDisplayFrame(windowRect);
//if there is toolBar, get his height
int actionBarHeight = 0;
Context context = view.getContext();
if (context instanceof AppCompatActivity && ((AppCompatActivity) context).getSupportActionBar() != null)
actionBarHeight = ((AppCompatActivity) context).getSupportActionBar().getHeight();
else if (context instanceof Activity && ((Activity) context).getActionBar() != null)
actionBarHeight = ((Activity) context).getActionBar().getHeight();
//windowAvailableRect - takes into account toolbar height and statusbar height
Rect windowAvailableRect = new Rect(windowRect.left, windowRect.top + actionBarHeight, windowRect.right, windowRect.bottom);
//viewRect - holds position of the view in window
//(methods as getGlobalVisibleRect, getHitRect, getDrawingRect can return different result,
// when partialy visible)
Rect viewRect;
final int[] viewsLocationInWindow = new int[2];
view.getLocationInWindow(viewsLocationInWindow);
int viewLeft = viewsLocationInWindow[0];
int viewTop = viewsLocationInWindow[1];
int viewRight = viewLeft + view.getWidth();
int viewBottom = viewTop + view.getHeight();
viewRect = new Rect(viewLeft, viewTop, viewRight, viewBottom);
//return true, only if the WHOLE view is visible in window
return windowAvailableRect.contains(viewRect);
}
you can add to your CustomView's constractor a an onScrollChangedListener from ViewTreeObserver
so if your View is scrolled of screen you can call view.getLocalVisibleRect() and determine if your view is partly offscreen ...
you can take a look to the code of my library : PercentVisibleLayout
Hope it helps!
in your custom view, set the listeners:
getViewTreeObserver().addOnScrollChangedListener(this);
getViewTreeObserver().addOnGlobalLayoutListener(this);
I am using this code to animate a view once when it is visible to user.
2 cases should be considered.
Your view is not in the screen. But it will be visible if user scrolled it
public void onScrollChanged() {
final int i[] = new int[2];
this.getLocationOnScreen(i);
if (i[1] <= mScreenHeight - 50) {
this.post(new Runnable() {
#Override
public void run() {
Log.d("ITEM", "animate");
//animate once
showValues();
}
});
getViewTreeObserver().removeOnScrollChangedListener(this);
getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
}
Your view is initially in screen.(Not in somewhere else invisible to user in scrollview, it is in initially on screen and visible to user)
public void onGlobalLayout() {
final int i[] = new int[2];
this.getLocationOnScreen(i);
if (i[1] <= mScreenHeight) {
this.post(new Runnable() {
#Override
public void run() {
Log.d("ITEM", "animate");
//animate once
showValues();
}
});
getViewTreeObserver().removeOnGlobalLayoutListener(this);
getViewTreeObserver().removeOnScrollChangedListener(this);
}
}

Categories

Resources