How to implement pan and zoom on the entire RecyclerView - android

I have my own custom view that extends RecyclerView and I use it to render a document with multiple pages. Each item in the adapter is a page.
I want to add the ability to zoom and pan the entire document (not each page individually).
I have tried following the Android guide here but I still can't get both zoom and pan to work properly.
My current approach is this:
Have a GestureDetector to detect pan gestures
Have a ScaleGestureDetector to detect pinch gestures
When each of these detectors register events, save the information regarding the current scale factor and the pan, call invalidate() and modify the Canvas in the drawChild method appropriately.
It is the last step that I'm unsure how to implement properly.
I'm using this data class to keep track of the view's current pan and zoom:
private data class PanAndZoom(
var scaleFactor: Float,
var focusX: Float,
var focusY: Float,
var panX: Float ,
var panY: Float
)
// field in my custom view initialised like this:
private val panAndZoom = PanAndZoom(1f, 0f, 0f, 0f, 0f)
and here is how I update the values when gestures are received:
private val scaleGestureDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
panAndZoom.scaleFactor *= detector.scaleFactor
panAndZoom.focusX = detector.focusX
panAndZoom.focusY = detector.focusY
invalidate()
return true
}
}
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
panAndZoom.panX -= distanceX
panAndZoom.panY -= distanceY
invalidate()
return true
}
}
I override two methods in my custom view, one to pass touch events to my gesture detectors and one to draw child view:
override fun onTouchEvent(e: MotionEvent): Boolean {
gestureDetector.onTouchEvent(e)
scaleGestureDetector.onTouchEvent(e)
return super.onTouchEvent(e)
}
override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
val save = canvas.save()
canvas.scale(panAndZoom.scaleFactor, panAndZoom.scaleFactor, panAndZoom.focusX, panAndZoom.focusY)
canvas.translate(panAndZoom.panX, panAndZoom.panY)
val result = super.drawChild(canvas, child, drawingTime)
canvas.restoreToCount(save)
return result
}
However this doesn't give the expected result, namely:
I am able to overscroll outside the RV altogether (I realise I'm not restricting my pan values, but how do I do that)?
Pan seems to be going very fast when zoomed in (how do I scale my pan values so that they respect the scale factor)?
If I am on page 2 of the document, and I zoom in, then pan to page 3, the layout manager doesn't realise that the view was actually scrolled (meaning, calling findFirstVisibleItem() will return the second page when it isn't actually visible.
Most likely related to the point above: I can pan past a page, and because the layout manager doesn't realise that another view is now displayed it doesn't render it (because it still thinks that view is hidden from view and so it optimises by not rendering it).

Related

Could somebody explain to me how this custom View code works?

class DrawingView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
private lateinit var mDrawPath: FingerPath
private lateinit var mCanvasBitmap: Bitmap
private lateinit var mCanvasPaint: Paint
private lateinit var mDrawPaint: Paint
private var mBrushSize = 0
private var color = Color.BLACK
private lateinit var canvas: Canvas
init {
setUpDrawing()
}
private fun setUpDrawing() {
mDrawPaint = Paint()
mDrawPath = FingerPath(color, mBrushSize.toFloat())
mDrawPaint.color = color
mDrawPaint.style = Paint.Style.STROKE
mDrawPaint.strokeJoin = Paint.Join.ROUND
mDrawPaint.strokeCap = Paint.Cap.ROUND
mCanvasPaint = Paint(Paint.DITHER_FLAG)
mBrushSize = 20
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
mCanvasBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
canvas = Canvas(mCanvasBitmap)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(mCanvasBitmap, 0f, 0f, mDrawPaint)
if (!mDrawPath.isEmpty) {
mDrawPaint.strokeWidth = mDrawPath.brushThickness
mDrawPaint.color = mDrawPath.color
canvas.drawPath(mDrawPath, mDrawPaint)
}
}
override fun onTouchEvent(event: MotionEvent?): Boolean {
val touchX = event?.x
val touchY = event?.y
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
mDrawPath.color = color
mDrawPath.brushThickness = mBrushSize.toFloat()
mDrawPath.reset()
mDrawPath.moveTo(touchX!!, touchY!!)
}
MotionEvent.ACTION_MOVE -> {
mDrawPath.lineTo(touchX!!, touchY!!)
}
MotionEvent.ACTION_UP -> {
mDrawPath = FingerPath(color, mBrushSize.toFloat())
}
else -> return false
}
invalidate()
return true
}
internal inner class FingerPath(var color: Int, var brushThickness: Float) : Path()
}
So, I'm taking a course about Android Development and the instructor is building a drawing app -- and I can't understand how to code works, because he's not explaining the 'why'; so, he types a lot of code without explaining why he uses that variable or why is he overring those functions; therefore, I don't understand how to code work. Could you help me in understanding how this code work?
class DrawingView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
private lateinit var mDrawPath: FingerPath
private lateinit var mCanvasBitmap: Bitmap
private lateinit var mCanvasPaint: Paint
private lateinit var mDrawPaint: Paint
private var mBrushSize = 0
private var color = Color.BLACK
private lateinit var canvas: Canvas
init {
// init block will called first when instance will be created so we
// are calling method setUpDrawing() as it is initialising everything
// that required to draw like color , brush size , brush behaviour
// (round , stroke etc .. ) . in simple manner , we can say painter is
// collecting all required tools before starting to paint
setUpDrawing()
}
private fun setUpDrawing() {
mDrawPaint = Paint()
mDrawPath = FingerPath(color, mBrushSize.toFloat())
mDrawPaint.color = color
mDrawPaint.style = Paint.Style.STROKE
mDrawPaint.strokeJoin = Paint.Join.ROUND
mDrawPaint.strokeCap = Paint.Cap.ROUND
mCanvasPaint = Paint(Paint.DITHER_FLAG)
mBrushSize = 20
}
// this method is going to be called by system when size is going to be
// changed so we are here creating blank board on which we are going to
// draw
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
mCanvasBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
canvas = Canvas(mCanvasBitmap)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(mCanvasBitmap, 0f, 0f, mDrawPaint)
if (!mDrawPath.isEmpty) {
mDrawPaint.strokeWidth = mDrawPath.brushThickness
mDrawPaint.color = mDrawPath.color
canvas.drawPath(mDrawPath, mDrawPaint) // drawing path on canvas
}
}
// this method will be called by system when user is going to touch screen
override fun onTouchEvent(event: MotionEvent?): Boolean {
val touchX = event?.x
val touchY = event?.y
when (event?.action) {
// this event will be fired when user put finger on screen
MotionEvent.ACTION_DOWN -> {
mDrawPath.color = color
mDrawPath.brushThickness = mBrushSize.toFloat()
mDrawPath.reset() // reseting path before we set inital point
mDrawPath.moveTo(touchX!!, touchY!!)// set point from where drawing will be started
}
// this event will be fired when user start to move it's fingure . this will be fired continually until user pickup fingure
MotionEvent.ACTION_MOVE -> {
mDrawPath.lineTo(touchX!!, touchY!!)
}
// this event will be fired when user will pick up fingure from screen
MotionEvent.ACTION_UP -> {
mDrawPath = FingerPath(color, mBrushSize.toFloat())
}
else -> return false
}
invalidate() / /refreshing layout to reflect drawing changes
return true
}
internal inner class FingerPath(var color: Int, var brushThickness: Float) : Path()
}
This code is a custom view class in Android for drawing. The class extends the View class and implements touch event handling to allow a user to draw on the screen.
The class defines instance variables to store the paint, path, bitmap and canvas used for drawing. It also has variables for brush size and color.
In the setUpDrawing method, the paint object for drawing and the path object for tracking the user's touch are set up.
In onSizeChanged, the bitmap object is created and its canvas object is initialized.
In onDraw, the canvas is drawn on the screen and the path is drawn on the canvas if it's not empty.
The onTouchEvent method handles touch events (DOWN, MOVE, UP) and updates the path accordingly.
The FingerPath inner class extends the Path class and contains the color and brush size of the path.
Have you read through this section of the docs? Custom View Components
It explains the basics of creating a custom view, which on a basic level means overriding onDraw (where you draw the contents of the view) and maybe onSizeChanged (where you're told how big the view is - maybe you want to set the thickness of your paint brushes, work out where the centre of the view is etc).
onTouchEvent is a little more advanced, that allows you to handle the user poking at your view - the link up there gets into it, but you'll have a better understanding if you read the section on touch gestures. Broadly, you can get info about interaction events through onTouchEvent and work out things like whether the user touched the view, moved a finger, lifted it or moved it out of bounds etc. You'd use this info to, say, draw stuff under the user's finger for a painting app.
A Canvas is a drawing interface for a Bitmap. Instead of having to work out what pixels to change to what colour to draw a line or whatever, you do all your drawing through a Canvas that wraps that Bitmap. This lets you abstract the process and do high-level stuff like "draw a line", "draw an arc", "draw this entire path", "render this text" and so on. The Canvas works out how to make that happen in the Bitmap's pixel array.
The drawing happens with Paints, which you can think of as an object that says how the drawing happens. What does drawing a line look like? The Paint you use defines things like stroke width, transparency, colour etc - it's like the tool options in a paint program. It also allows you to set things like text size (if you're using it to render text) and do fancy things with blending over the pixels it's being drawn on, create shaders etc. It can also do things like control tiling if you're using a bitmap as the source image.
Generally you'll want to create these Paints at startup and reuse them during your View's lifespan, so it's typical to have a few of them - the one for painting the background, the one for drawing curves, the one for rendering text etc.
Usually in onDraw you just draw the view in its current state. The method takes a Canvas parameter - that's how you draw to the actual bitmap rendered on the UI. So if you had a knob, you could draw the circle, draw the position indicator at the appropriate location, add any text, and you're done!
For a drawing app it's a little more complicated - that "state" you need to remember and draw is the sum of all the drawing operations that have happened. You can get fancy with this (to create levels of undo) but the simplest approach is to create your own Bitmap to store the current drawing. You wrap that in its own Canvas (so you can draw on it) and then all the user interaction draws stuff to that Canvas, so you're painting on the bitmap. Then, in onDraw, you basically just need to paint the contents of that bitmap onto the system-provided Canvas so it gets drawn on-screen - just showing what you have, basically.
The code you have here is a little weird - you have that internal bitmap (called mCanvasBitmap) wrapped in a Canvas (called canvas) but you never actually draw to it. I feel like this line is meant to be rendering the Path you build up with the touch gestures:
override fun onDraw(canvas: Canvas) {
...
if (!mDrawPath.isEmpty) {
...
canvas.drawPath(mDrawPath, mDrawPaint) // drawing path on canvas
}
}
but in onDraw's scope canvas is referring to that parameter being passed in, i.e. the Canvas for drawing to the screen, not the one wrapping mCanvasBitmap. So that internal bitmap always stays empty, and then you just draw the most recent path (this is a good example of watching how you name things - why not mCanvas to be consistent?). It never "builds up" an image by drawing paths onto your internal bitmap. I can't tell what the exact intent is from the code though, I just thought I'd point it out!

How to drag to pan a view in Android?

I have a LinearLayoutCompat that doesn't fit into the screen. So, I decided to implement 'drag to pan' feature for the user to scroll to any part of the layout (in all directions). It's suggested that we should do it like the code snippet on Android's official website:
// The current viewport. This rectangle represents the currently visible
// chart domain and range.
private val mCurrentViewport = RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX)
// The current destination rectangle (in pixel coordinates) into which the
// chart data should be drawn.
private val mContentRect: Rect? = null
private val mGestureListener = object : GestureDetector.SimpleOnGestureListener() {
...
override fun onScroll(
e1: MotionEvent,
e2: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
// Scrolling uses math based on the viewport (as opposed to math using pixels).
mContentRect?.apply {
// Pixel offset is the offset in screen pixels, while viewport offset is the
// offset within the current viewport.
val viewportOffsetX = distanceX * mCurrentViewport.width() / width()
val viewportOffsetY = -distanceY * mCurrentViewport.height() / height()
// Updates the viewport, refreshes the display.
setViewportBottomLeft(
mCurrentViewport.left + viewportOffsetX,
mCurrentViewport.bottom + viewportOffsetY
)
}
return true
}
}
The problem is I don't understand what the mCurrentViewPort variable is and how I should obtain the values for AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX and AXIS_Y_MAX to pass to the RecF object. Also, setViewPortBottomLeft(...) is marked as unresolved reference for me.

Android swipe right/left on whole recyclerview

I found a lot of examples for swipe detection on a recyclerview item. But I try to detect right/left swipe on the whole view.
There are also examples like this Android: How to handle right to left swipe gestures
on how to detect swipe on views like layout container. But attaching this touch listener to the recyclerview is not working.
So how can I detect left / right swipe on a whole recyclerview?
Edit for SmartSwipe Lib:
I have tried to add the https://github.com/luckybilly/SmartSwipe Lib because the effect of swipe looks really nice.
But if I try to wrap it to the recyclerview I see no data only the swipe effect:
SmartSwipe.wrap(binding.recyclerView).addConsumer(BezierBackConsumer())
.enableHorizontal()
.addListener(object : SimpleSwipeListener() {
override fun onSwipeOpened(wrapper: SmartSwipeWrapper, consumer: SwipeConsumer, direction: Int) {
}
})
Attaching it to the constraint layout container has no effect at all.
Edit 2:
Attaching an onTouchListener to the view doesn't work for the recyclerview:
view.setOnTouchListener(object : OnHorizontalSwipeListener(requireContext()) {
override fun onRightSwipe() {
println("Swipe right")
}
override fun onLeftSwipe() {
println("Swipe left")
}
})
Attaching it to the Recyclerview leads to an exception:
binding.recyclerView.setOnTouchListener.....
E/MessageQueue-JNI: java.lang.NullPointerException: Parameter specified as non-null is null: method kotlin.jvm.internal.Intrinsics.checkNotNullParameter, parameter e1
at ui.OnHorizontalSwipeListener$GestureListener.onFling(Unknown Source:2)
Edit 3: Seems to work if I add null check to the listener class like this:
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
var result = false
try {
if (e1 != null && e2 != null) {
val diffY = e2.y - e1.y
val diffX = e2.x - e1.x
....
But starts working only after some scrolling. trying to swipe on a new initialized fragment doesn't work.

Android - Trigger MainActivity to do something from custom View

I would like to let my MainActivity know that there is something to do (e.g. calculate stuff) from a custom View. The View detects user inputs through touch and the MainActivity has to update certain user controls with a calculation from those View-Values. Basically I did an override on onTouchEvent:
override fun onTouchEvent(event: MotionEvent?): Boolean {
val x = event?.x
val y = event?.y
val dX = x?.minus(prevX)
val dY = y?.minus(prevY)
if(dX!! > 0){
lowerBound = x.toInt()
} else{
upperBound = x.toInt()
}
prevX = x!!.toInt()
prevY = y!!.toInt()
this.invalidate() //tell view to redraw
return true
}
How can I let MainActivityknow that lowerBound and upperBound updated?
As already recommended in the comments I'll go a little bit deeper in "hot to use a listener".
First of all, a "Listener" is nothing more than a interface which get called if something happen elsewhere. The best example is the View.setOnClickListener(View).
How to use that in your case?
Simply define a interface (best location is here inside your custom View):
interface OnBoundsUpdatedListener {
fun onBoundsUpdated(upperBound: Int, lowerBound: Int)
}
Then you have to create a property in your CustomView and call the Listener if the bounds have changed :
// Constructor and stuff
val onBoundsUpdateListener: OnBoundsUpdatedListener? = null
override fun onTouchEvent(event: MotionEvent?): Boolean {
...
onBoundsUpdateListener?.onBoundsUpdated(upperBound, lowerBound)
...
}
In your Activtiy you can find that View and set the listener:
val myCustomView = findViewById<MyCustomView>(R.id.id_of_your_customview)
myCustomView.onBoundsUpdateListener = object : OnBoundsUpdatedListener {
override fun onBoundsUpdated(upperBound, lowerBound) {
// Get called if its called in your CustomView
}
}
Note: The code can be simplified. But that is the basic stuff 😉

Android - Dragging in MPAndroidChart is unresponsive when contained in ScrollView

I have a LineChart contained within a ScrollView. When the chart is long enough for it to be necessary for a user to scroll to see it in its entirety, the drag features become unresponsive. Drag gestures only register when I hold my finger down for a short period of time in the chart bounds and then drag.
I have tried using the requestDisallowInterceptTouchEvent method that should prevent the chart's parents from intercepting the touch events (but this doesn't solve anything). I've also tried directly passing MotionEvents registered by the ScrollView straight to the chart/translating drag gestures to translateY calls but this doesn't do what I thought it would.
Note: Zooming continues to work perfectly.
Also, this is not an issue when the graph fits in the original window or when it's placed in any view that is not a ScrollView. I have considered getting rid of the ScrollView but it's a pretty necessary feature in my project.
Any ideas on why this could be happening would be appreciated!
Edit: the LineChart has a fixed height
This question is kind of old but I run into the same problem recently and to solve it, I did the following.
Added a transparent view over the graph on the xml.
Set setOnTouchListener on the view
ex.
clickInterceptorGraph.setOnTouchListener(new View.OnTouchListener() {
#Override
public boolean onTouch(View v, MotionEvent event) {
return onTouchActionHandler(v, event);
}
});
On the MotionEvent Action Down, I'm setting requestDisallowInterceptTouchEvent to true and returning false so the event won't be consumed, so while the user is pressing on the transparent image over the graph requestDisallowInterceptTouchEvent will be true and it will disable the Touch Event on the scrollview which will disable the scroll and when you release the touch the scrollview will work normally again.
ex.
protected boolean onTouchActionHandler(View v, MotionEvent event){
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
// Disallow RecyclerView to intercept touch events.
scrollView.requestDisallowInterceptTouchEvent(true);
Log.e(TAG, "onTouchActionHandler: ACTION_DOWN" );
// Disable touch on transparent view
return false;
default:
return true;
}
}
*** You could do everything on the setOntouchListener but in my case I need to reuse the requestDisallowInterceptTouchEvent.
Similar approach as Ruan_Lopes mentioned, you just don't need a transparent overlay view. You can also use chart gesture listener to intercept touches the same way.
lineChart.onChartGestureListener = object : OnChartGestureListener {
override fun onChartGestureEnd(
me: MotionEvent?,
lastPerformedGesture: ChartTouchListener.ChartGesture?
) = Unit
override fun onChartFling(
me1: MotionEvent?,
me2: MotionEvent?,
velocityX: Float,
velocityY: Float
) = Unit
override fun onChartSingleTapped(me: MotionEvent?) = Unit
override fun onChartGestureStart(
e: MotionEvent?,
lastPerformedGesture: ChartTouchListener.ChartGesture?
) = recyclerView.requestDisallowInterceptTouchEvent(true)
override fun onChartScale(me: MotionEvent?, scaleX: Float, scaleY: Float) = Unit
override fun onChartLongPressed(me: MotionEvent?) = Unit
override fun onChartDoubleTapped(me: MotionEvent?) = Unit
override fun onChartTranslate(me: MotionEvent?, dX: Float, dY: Float) = Unit
}

Categories

Resources