In an Android photo viewing app, I want to allow users to:
Zoom into a photo, drag to see its details, unzoom.
Swipe to go to the next photo.
Implementation attempt using ZoomableDraweeView in Facebook's Fresco library:
private fun init(imageUri: Uri?) {
val hierarchy = GenericDraweeHierarchyBuilder.newInstance(resources)
.setActualImageScaleType(ScalingUtils.ScaleType.FIT_CENTER)
.setProgressBarImage(ProgressBarDrawable())
.setProgressBarImageScaleType(ScalingUtils.ScaleType.FIT_CENTER)
.build()
zoomableDraweeView!!.hierarchy = hierarchy
zoomableDraweeView!!.setAllowTouchInterceptionWhileZoomed(false)
zoomableDraweeView!!.setIsLongpressEnabled(false)
zoomableDraweeView!!.setTapListener(DoubleTapGestureListener(zoomableDraweeView))
val controller: DraweeController = Fresco.newDraweeControllerBuilder()
.setUri(imageUri)
.setControllerListener(loadingListener)
.build()
zoomableDraweeView!!.controller = controller
Problem: When I zoom in, lift the fingers, then try to unzoom, this gets misinterpreted as a swipe and I am randomly sent to the next picture.
What am I doing wrong? How to disable swipes when zoomed in (or any better UX solution)?
I specifically call setAllowTouchInterceptionWhileZoomed(false), whose javadoc says: "If this is set to true, parent views can intercept touch events while the view is zoomed. For example, this can be used to swipe between images in a view pager while zoomed."
I tried to execute the swipe action only when zoomableDraweeView.getZoomableController().isIdentity() is false, but that does not always prevent unintended swipe. In particular, when I fully zoom out, often swipe accidentally happens, maybe because isIdentity() has been updated by the time I release all fingers. getScaleFactor() has the same issue. Another issue is that this solution only allows me to drag the zoomed picture with two fingers, dragging with one finger has no effect.
I thought about writing my own DraweeController and surface the zoom level (and ignore swipes when zoomed) but the base classes do not seem to contain any zoom level information.
By the way, here is how I detect swipes:
open class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
private inner class GestureListener :
GestureDetector.SimpleOnGestureListener() {
override fun onFling(
event1: MotionEvent,
event2: MotionEvent,
velocityX: Float,
velocityY: Float
): Boolean {
try {
val diffX: Float = event2.x - event1.x
if (abs(diffX) > abs(diffY)) {
if (abs(diffX) > SWIPE_THRESHOLD && abs(velocityX) >
SWIPE_VELOCITY_THRESHOLD) {
if (diffX > 0) {
goToTheNextPhoto() // Swipe detected.
(full code on GitHub if needed)
The issue is not in the Fresco library or in the ZoomableDraweeView. The problem is in the orchestrating of the overall UX in your activity. It is not hard though, and you were on the right track with the zoomableDraweeView.getZoomableController().isIdentity() and setAllowTouchInterceptionWhileZoomed.
There are several ways to overcome it, starting from the custom ZoomableController and ending by exchanging your swipes detector with a proper ViewPager2(RecyclerView based)(or even two as you have vertical and horizontal swipes) with snapping and its own callbacks and animations. But the solution will be similar in all the cases - using everything where(and when) it is supposed to be used. It may require a bit of refactoring of your overall approach.
The first way to fix it is via setAllowTouchInterceptionWhileZoomed, which is not working for you because you apply your swipe detector directly to the ZoomableDraweeView rather than to its parent. Thus your view parent cannot handle swipes because you told it not to via setAllowTouchInterceptionWhileZoomed, but your ZoomableDraweeView can, and it does. Thus making you ZoomableDraweeView parent handle swipes rather than the view itself should do the trick.
Secondly, the ZoomableController is AnimatedZoomableController thus, it performs animation, and the animation has a duration(in this case, it depends on the pinch gesture velocity) - in the case when you were zooming out, and your swipe detector was changing the image - the animation was still ongoing, but the transformation matrix was already back to identity - thus the issue.
To fix this, you have to consider animation duration. I think even a simple 200ms delay should fix it in most cases. Also, I would suggest having an onTransformChanged method of the ZoomableDraweeView view(making a custom view) overridden, checking for the identity matrix in it, and exposing it to the activity in some way - this way, you will be sure that the transformation has already happened(the animation delay is still relevant in this case), and you can easily enable your own swipe detection.
The first method is preferable and more "clean," and the second one is "hacky," but its minimal implementation should be very fast.
I haven't tried to refactor it so I assume some additional work may be required.
Hope it helps.
Related
We have pointerInput for detecting tap, drag and pan events, and it also provides a handy awaitPointerEventScope, the pointer being the finger, for mobile devices here. Now, we do have a awaitFirstDown() for detecting when the finger first makes contact with the screen, but I can't seem to find an upper equivalent to this method.
I have a little widget that I wish to detect taps on, but the thing is that the app is made for such a use-case that the user might be in weird positions during its use, and so I wished to have it trigger the required action on just a touch and lift of the finger. The paranoia is that the user might accidentally 'drag' their finger (even by a millimeter, android still picks it up), and I do not want that to be the case. I could implement a tap as well as a drag listener, but none of them offer a finger-lift detection, as far as I know.
What solution, if there is one as of now, is suitable for the use-case while adhering to and leveraging the declarative nature of Compose while keeping the codebase to a minimum?
Better way, and what is suggested by Android code if you are not using interoperability with existing View code is Modifier.pointerInput()
A special PointerInputModifier that provides access to the underlying
MotionEvents originally dispatched to Compose. Prefer pointerInput and
use this only for interoperation with existing code that consumes
MotionEvents. While the main intent of this Modifier is to allow
arbitrary code to access the original MotionEvent dispatched to
Compose, for completeness, analogs are provided to allow arbitrary
code to interact with the system as if it were an Android View.
val pointerModifier = Modifier
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
// ACTION_DOWN here
do {
//This PointerEvent contains details including
// event, id, position and more
val event: PointerEvent = awaitPointerEvent()
// ACTION_MOVE loop
// Consuming event prevents other gestures or scroll to intercept
event.changes.forEach { pointerInputChange: PointerInputChange ->
pointerInputChange.consumePositionChange()
}
} while (event.changes.any { it.pressed })
// ACTION_UP is here
}
}
}
This answer explains in detail how it works, internals and key points to consider when creating your own gestures.
Also this is a gesture library you can check out for onTouchEvent counterpart and 2 for detectTransformGestures with onGestureEnd callback and returns number of pointers down or list of PointerInputChange in onGesture event. Which can be used as
Modifier.pointerMotionEvents(
onDown = {
// When down is consumed
it.consumeDownChange()
},
onMove = {
// Consuming move prevents scroll other events to not get this move event
it.consumePositionChange()
},
onUp= {}
delayAfterDownInMillis = 20
)
Edit
As of 1.2.0-beta01, partial consumes like
PointerInputChange.consemePositionChange(),
PointerInputChange.consumeDownChange(), and one for consuming all changes PointerInputChange.consumeAllChanges() are deprecated
PointerInputChange.consume()
is the only one to be used preventing other gestures/event.
pointerInteropFilter is the way to go
Item(
Modifier.pointerInteropFilter {
if (it.action == MotionEvent.ACTION_UP) {
triggerAction()
}
true // Consume touch, return false if consumption is not required here
}
)
I'm working on an android library to display an image as a fixed background image. To do this, I'm dynamically ajusting the position of the image every 10ms based on the locationOnScreen. I understand that it's an aweful solution, but I'm here to improve this :)
The issue with this is that there is a glitch when the parent scrollable view is scrolling too fast and the image jump while the other view are moving. (click on the gif for the full demo)
As it's a library, I don't want to add complexity when integrating the lib, meaning no scroll listener, theme or no window override etc.
Solution tried:
- changing the loop delay
- using window background is not possible for a library
- no access to activity theme or similar
handler
override fun run() {
fixedBackgroundImageLayout.getLocationOnScreen(locationOnScreen)
fixedBackgroundImagePlugin.update(locationOnScreen)
handler.postDelayed(this, 10)
}
FixedBackgroundImagePlugin#update
override fun update(locationOnScreen: IntArray) {
if (backgroundImageFrameLayout == null) {
return
}
yPosition = parentLocationOnScreen[1] - locationOnScreen[1]
backgroundImageFrameLayout.top = 2 * yPosition - lastYPosition - 10
lastYPosition = yPosition
}
the backgroundImageFrameLayout has the image as a background image.
I've also setup a sample repository to help you dig in if wanted.
I'm open to any advice/lead
Update: Previous code I posted had some interaction issues that, surprisingly, made the code work but pegged the CPU. The following code is substantially like the previous code, but behaves. Although the new code involves a scroll listener that causes the view to redraw itself, it is self-contained. The scroll listener is needed to detect when the view is re-positioned on the screen. When called, the scroll listener simply invalidates the view.
You can do what you need all within the custom view BackgroundImageFrameLayout. Here is a draw() override that will take care of drawing the background of the FrameLayout that holds the image.
Add the following to the init block:
viewTreeObserver.addOnScrollChangedListener {
invalidate()
}
Add the following override method to BackgroundImageFrameLayout:
override fun onDraw(canvas: Canvas) { // draw(canvas) may be better choice.
if (background == null) {
return
}
// Get the location of this view on the screen to compute its top and bottom coordinates.
val location = IntArray(2)
getLocationOnScreen(location)
val imageTop = location[1]
val imageBottom = imageTop + height
// Draw the slice of the image that should be viewable.
canvas.save()
canvas.translate(0f, -imageTop.toFloat())
background?.setBounds(0, imageTop, width, imageBottom)
background?.draw(canvas)
canvas.restore()
}
Remove everything else that tries to manipulate the background - you won't need it.
Here is a demo of the app with the new onDraw():
I think there may be some other issues with the size of the screen vs. the size of the image, but this is the direction that you should go (IMHO.)
I've noticed that all the MotionLayouts so far always snap to one of their end states, even if you drag a view halfway through the animation, stop, and let go. What I'm trying to achieve is to have the motionlayout only act on user interaction and momentum. If the user lets go in the middle of an animation it should stop completely once momentum runs out and not keep interpolating until an explicitly defined state, which is the default behaviour.
Lists, scrollviews, coordinatorlayouts all support this "halfway" stop, so why not motionlayout?
So does anyone knows how to freeze the progress of a motionlayout between states? It should work for any position on the timeline so I don't want to add extra states, as it would require a 100 of them.
Add an onTouchUp to your OnSwipe. stop is probably what you want. decelerate seems to be buggy. And stop is more of a deceleration anyways if it's connected to a scrolling view since the view will provide its own deceleration.
<OnSwipe
app:dragDirection="dragUp"
app:onTouchUp="stop"
app:touchAnchorId="#id/scrollView"
app:touchAnchorSide="top" />
Found this here: https://mikescamell.com/motionlayoutquickie-ontouchup/
You can set the position with motionLayout.setProgress() method, and do it or when the transition is changing or when you release your finger - on MotionEvent.ACTION_UP in the OnTouch method.
First variant:
motionLayout.setTransitionListener(new MotionLayout.TransitionListener() {
// Other methods
#Override
public void onTransitionChange(MotionLayout motionLayout, int i, int i1, float v) {
// v == motionLayout.getProgress();
motionLayout.setProgress(v);
}
// Other methods
});
Second variant:
if (motionEvent.getAction() == MotionEvent.ACTION_UP) {
motionLayout.setProgress(motionLayout.getProgress());
}
Can one undo the changes he made on View properties using animate() on it?
In particular, how to undo changes made using animate().yBy(x)?
Note that I tried using animate().yBy(-x) and it works most of the times, but there are times that for some reason animate().yBy(x) seem not to be completed correctly (especially when the fragment pauses and then resumed) so animate().yBy(-x) is over-moving the view.
I'm looking for a way to make the View reset its properties to the way they were before I changed them using animate().
xBy() and yBy() animations affect the translationX and translationY properties. You can get the current values of those properties via getTranslationX() and getTranslationY(). So, to undo the previous animations, multiply the current property values by -1 and animate those. Or if you are seeking a "smash cut" jump (no animation), just call setTranslationX(0) or setTranslationY(0).
By using interpolator we can inverse the animation:
public class InverAnim implements Interpolator {
#Override
public float getInterpolation(float paramFloat) {
return Math.abs(paramFloat -1f);
}
}
On your animation you can set, new interpolator:
myAnimation.setInterpolator(new InverAnim());
simple question, is there ViewPager.PageTransformer that animates a page curl effect?
I've been looking everywhere, but I couldn't find it and wouldn't really know how to implement it myself...
Thanks in advance,
Cédric
I think #Cédric was right — it's probably not worth it.
So I managed to get this working, but there's a lot of ugliness in there.
There's some duct tape in the software architecture. Because page transformers only work on views, the app depends on using a custom layout that can handle drawing the page curl. So the transformer looks like this:
public static class PageCurlPageTransformer implements PageTransformer {
#Override
public void transformPage(View page, float position) {
Log.d(TAG, "transformPage, position = " + position + ", page = " + page.getTag(R.id.viewpager));
if (page instanceof PageCurl) {
if (position > -1.0F && position < 1.0F) {
// hold the page steady and let the views do the work
page.setTranslationX(-position * page.getWidth());
} else {
page.setTranslationX(0.0F);
}
if (position <= 1.0F && position >= -1.0F) {
((PageCurl) page).setCurlFactor(position);
}
}
}
}
To get a realistic page-turn reveal, you have to use Canvas.clipPath. I had trouble getting this to clip to the actual path when testing on an emulator. I had to resort to testing on the device to see clipPath work correctly. Even turning off hardware acceleration didn't work on the emulator. This gives me low confidence that this will look right on all devices all the time.
I just drew the curling corner with dispatchDraw in the custom layout. That's probably not the best place to do it. If I were taking more time, I would probably have a special decor view in the view pager and have that draw the curl.
You might notice that the paging in reverse is faster than paging forward. You might not like the animation speed in either forward or reverse. Too bad — ViewPager doesn't have any methods to tweak the fling speeds, so you get what you get. This is yet another limitation of using ViewPager for this sort of thing.
You can look at my project as a proof-of-concept that you can indeed get a page curl to work with a view pager and a page transformer. Hopefully this will give you what you need to implement it in your project.
Project is here: https://github.com/klarson2/PageCurlWithPageTransformer
Cheers