I have a ViewPager with three fragments inside of a SwipeRefreshLayout. The problem I'm seeing is that when attempting to swipe between fragments in the ViewPager, sometimes the SwipeRefreshLayout will take over and stop the interaction with the ViewPager. It appears to do so if the gesture for the ViewPager interaction goes vertical even a tiny bit.
I found a few questions on StackOverflow that were somewhat similar to mine, but none of them quite fit comprehensively. Here's my solution.
A custom SwipeRefreshLayout that can toggle its InterceptTouchEvent:
class ToggleableSwipeRefreshLayout : SwipeRefreshLayout {
private var isDisabled = false
private var touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var prevX = 0f
constructor(context: Context) : super(context)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
fun setDisabled(isDisabled: Boolean) {
this.isDisabled = isDisabled
parent.requestDisallowInterceptTouchEvent(isDisabled)
}
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
val event = MotionEvent.obtain(ev)
prevX = event.x
event.recycle()
}
MotionEvent.ACTION_MOVE -> {
if (isDisabled) { return false }
val eventX = ev.x
val xDiff = Math.abs(eventX - prevX)
if (xDiff > touchSlop) {
return false
}
}
}
return super.onInterceptTouchEvent(ev)
}
}
And in the Fragment/Activity this is being used in, add an OnPageChangeListener to the ViewPager to monitor the scroll state and enable/disable the SwipeRefreshLayout's InterceptTouchEvent accordingly:
viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrollStateChanged(state: Int) {
when (state) {
ViewPager.SCROLL_STATE_DRAGGING -> swipeLayout.setDisabled(true)
ViewPager.SCROLL_STATE_IDLE -> swipeLayout.setDisabled(false)
}
}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { }
override fun onPageSelected(position: Int) { }
})
Now it works great!
Related
I'm trying to do a proof of concept where a Webview loads local HTML files and users are able to swipe left/right to go to the next file, all while in fullscreen. I'm utilizing ViewPager2 and normal Webviews. I have that part working, but what I want to do is, upon the user doing a single tap, show or hide the toolbar, status bar and navigation controls. Right now I have the setOnTouchListener code on the viewPager, but it looks like the touch events are being consumed by the webview.
How can I accomplish where a single tap would do a toggle between fullscreen and non-fullscreen mode, without disrupting ViewPager paging and long press in the webview?
Here is most of my code. I'm leaving parts out for brevity that should not be related.
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_my)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
viewPager.setOnTouchListener { _, event ->
when(event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = event.rawX
initialY = event.rawY
moved = false
}
MotionEvent.ACTION_MOVE -> {
if (event.rawX != initialX || event.rawY != initialY) {
moved = true
}
}
MotionEvent.ACTION_UP -> {
if (!moved) {
toggle()
}
}
}
true
}
viewPager.adapter = MyAdapter(items, this)
}
}
class MyAdapter(private val items: List<String>) : RecyclerView.Adapter<MyViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.view_pager_item, parent, false) as WebView
return MyViewHolder(view, items)
}
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
holder.bind(position)
}
override fun getItemCount(): Int {
return items.size
}
}
class MyViewHolder (private val webView: WebView, private val items: List<String>) :
RecyclerView.ViewHolder(webView) {
internal fun bind(position: Int) {
webView.loadUrl("file://" + items[position])
}
}
I make bottom sheet fragment like this:
val bottomSheet = PictureBottomSheetFragment(fragment)
bottomSheet.isCancelable = true
bottomSheet.setListener(pictureListener)
bottomSheet.show(ac.supportFragmentManager, "PictureBottomSheetFragment")
But its not dismiss when I touch outside. and dismiss or isCancelable not working.
try this
behavior.setState(BottomSheetBehavior.STATE_HIDDEN));
You can override method and indicate, for example, in onViewCreated what you need:
class ModalDialogSuccsesDataPatient : ModalDialog() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
isCancelable = false //or true
}
}
Let's try to design reusable functions to solve this problem and similar ones if the need arises.
We can create extension functions on View that tell whether a point on the screen is contained within the View or not.
fun View.containsPoint(rawX: Int, rawY: Int): Boolean {
val rect = Rect()
this.getGlobalVisibleRect(rect)
return rect.contains(rawX, rawY)
}
fun View.doesNotContainPoint(rawX: Int, rawY: Int) = !containsPoint(rawX, rawY)
Now we can override the dispatchTouchEvent(event: MotionEvent) method of Activity to know where exactly the user clicked on the screen.
private const val SCROLL_THRESHOLD = 10F // To filter out scroll gestures from clicks
private var downX = 0F
private var downY = 0F
private var isClick = false
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
when (event.action and MotionEvent.ACTION_MASK) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
isClick = true
}
MotionEvent.ACTION_MOVE -> {
val xThreshCrossed = abs(downX - event.x) > SCROLL_THRESHOLD
val yThreshCrossed = abs(downY - event.y) > SCROLL_THRESHOLD
if (isClick and (xThreshCrossed or yThreshCrossed)) {
isClick = false
}
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
if (isClick) onScreenClick(event.rawX, event.rawY)
}
else -> { }
}
return super.dispatchTouchEvent(event)
}
private fun onScreenClick(rawX: Float, rawY: Float) { }
Now, you can simply use the above-defined functions to achieve the required result
private fun onScreenClick(rawX: Float, rawY: Float) {
if (bottomSheet.doesNotContainPoint(rawX.toInt(), rawY.toInt())) {
// Handle bottomSheet state changes
}
}
What more? If you have a BaseActivity which is extended by all your Activities then you can add the click detection code to it. You can make the onScreenClick an protected open method so that it can be overridden by the sub-classes.
protected open fun onScreenClick(rawX: Float, rawY: Float) { }
Usage:
override fun onScreenClick(rawX: Float, rawY: Float) {
super.onScreenClick(rawX, rawY)
if (bottomSheet.doesNotContainPoint(rawX.toInt(), rawY.toInt())) {
// Handle bottomSheet state changes
}
}
I wonder how I can achieve such behaviour of the RecyclerView, that I could scroll the list only when I click and drag only specific view within the ViewHolder?
I've disabled horizontal scroll by creating my own LinearLayoutManager:
class MyOwnLayoutManager : LinearLayoutManager {
constructor(context: Context?) : super(context)
constructor(context: Context?, orientation: Int, reverseLayout: Boolean) : super(context, orientation, reverseLayout)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
private var isScrollEnabled = false
fun setScrollEnabled(isEnabled: Boolean) {
isScrollEnabled = isEnabled
}
override fun canScrollVertically(): Boolean {
return isScrollEnabled && super.canScrollVertically()
}
override fun canScrollHorizontally(): Boolean {
return isScrollEnabled && super.canScrollHorizontally()
}
}
Then, I try to change the isScrollEnabled by setting the touch listener to the item's header:
item_header.setOnTouchListener { v, event ->
val isScrolling = event.action == ACTION_MOVE
onHeaderIsDragging.invoke(isScrolling)
false
}
Callback in the fragment that changes adapters' layout manager var:
private val onHeaderIsDragging: ((Boolean) -> Unit) = {
recyclerViewLayoutManager.setScrollEnabled(it)
}
By this implementation, I get MOTION_CANCEL after a couple of MOTION_MOVE events in the onTouchListener and RecyclerView is not scrollable after MOTION_MOVE events.
I am making a simple App which holds a list of timers and allows the user to scroll between these timers via ViewPager. Android Architecture Component principles are in effect here: the ViewModel holds the state of the app, and Fragments/Activities just handle UI interactions.
In this case, a custom ViewPager and FragmentStatePagerAdapter has been made. My question is, what is the best way to have the ViewPager/adapter respond to changes in the ViewModel?
The current code below works fine, but is there a better way to do this? Currently, both the ViewPager and Adapter require reference to the ViewModel and this feels contrary to what MVVM should implement.
ViewPager
class VerticalTimerViewPager(context: Context, attributeSet: AttributeSet): ViewPager(context, attributeSet){
var homeViewModel: HomeViewModel?=null
init {
setPageTransformer(true, VerticalPageTransformer())
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
ev ?: return false
val intercepted = super.onInterceptTouchEvent(swapXYOnMotionEvent(ev))
swapXYOnMotionEvent(ev)
return intercepted
}
override fun onTouchEvent(ev: MotionEvent?): Boolean {
homeViewModel ?: throwNoViewModelException()
when(ev?.action){
MotionEvent.ACTION_DOWN -> homeViewModel?.onSwipeDown()
MotionEvent.ACTION_UP -> homeViewModel?.onSwipeUp()
}
return super.onTouchEvent(swapXYOnMotionEvent(ev ?: return false))
}
private fun swapXYOnMotionEvent(motionEvent: MotionEvent): MotionEvent{
with(motionEvent){
val newX = (y/height)*width
val newY = (x/width) * height
setLocation(newX, newY)
}
return motionEvent
}
private fun throwNoViewModelException():Boolean{
throw RuntimeException("${this.javaClass.simpleName}: HomeViewModel must be set by setting the variable homeViewModel")
}
}
FragmentStatePagerAdapter
class VerticalTimerViewPager(context: Context, attributeSet: AttributeSet): ViewPager(context, attributeSet){
var homeViewModel: HomeViewModel?=null
init {
setPageTransformer(true, VerticalPageTransformer())
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
ev ?: return false
val intercepted = super.onInterceptTouchEvent(swapXYOnMotionEvent(ev))
swapXYOnMotionEvent(ev)
return intercepted
}
override fun onTouchEvent(ev: MotionEvent?): Boolean {
homeViewModel ?: throwNoViewModelException()
when(ev?.action){
MotionEvent.ACTION_DOWN -> homeViewModel?.onSwipeDown()
MotionEvent.ACTION_UP -> homeViewModel?.onSwipeUp()
}
return super.onTouchEvent(swapXYOnMotionEvent(ev ?: return false))
}
private fun swapXYOnMotionEvent(motionEvent: MotionEvent): MotionEvent{
with(motionEvent){
val newX = (y/height)*width
val newY = (x/width) * height
setLocation(newX, newY)
}
return motionEvent
}
private fun throwNoViewModelException():Boolean{
throw RuntimeException("${this.javaClass.simpleName}: HomeViewModel must be set by setting the variable homeViewModel")
}
}
Instantiation Method called in Activity onCreate()
private fun setupPagerAdapter() {
val pagerAdapter = TimerSlidePagerAdapter(viewModel = viewModel, fragmentManager = supportFragmentManager)
with(pager){
adapter = pagerAdapter
homeViewModel = viewModel
}
}
Looks like you're using ViewModel to communicate for onTouchEvent(ev: MotionEvent?) from your adapter & view pager, if you want to make your adapter & view pager independent form ViewModel i would suggest you following way:
Make one interface having methods onSwipeDown() & onSwipeUp() (or make any N numbers of method you want to have for ViewModel)
Take this interface object instead of ViewModel in your view pager & adapter.
Implement this interface to your ViewModel (which will expose methods of interface in ViewModel).
Cast your interface to ViewModel where you're passing ViewModel to adapter and view pager.
Voila! you've made your adapter & view pager independent to ViewModel's direct reference.
The Answer from Jeel Vankhede was marked as a solution and implemented. For future reference, the solution code will be pasted below to show the specifics of how the ViewPager and Adapter were successfully made independent of the ViewModel:
ViewPager:
notice we now have an Interface object. When the ViewPager is swiped, if there is no interface object, an exception is thrown.
//Overriding default touch events and swapping x/y coordinates prior to handling
class VerticalTimerViewPager(context: Context, attributeSet: AttributeSet): ViewPager(context, attributeSet){
var timerViewPagerEventListener: TimerViewPagerEvent? = null
init {
setPageTransformer(true, VerticalPageTransformer())
}
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
ev ?: return false
val intercepted = super.onInterceptTouchEvent(swapXYOnMotionEvent(ev))
swapXYOnMotionEvent(ev)
return intercepted
}
override fun onTouchEvent(ev: MotionEvent?): Boolean {
timerViewPagerEventListener ?: throwInterfaceExeption()
when(ev?.action){
MotionEvent.ACTION_DOWN -> timerViewPagerEventListener?.onViewPagerSwipeDown()
MotionEvent.ACTION_UP -> timerViewPagerEventListener?.onViewPagerSwipeUp()
}
return super.onTouchEvent(swapXYOnMotionEvent(ev ?: return false))
}
private fun throwInterfaceExeption():Boolean {
throw RuntimeException("${this.javaClass.simpleName}: Parent Activity must implement TimerViewPagerEvent interface" )
}
private fun swapXYOnMotionEvent(motionEvent: MotionEvent): MotionEvent{
with(motionEvent){
val newX = (y/height)*width
val newY = (x/width) * height
setLocation(newX, newY)
}
return motionEvent
}
interface TimerViewPagerEvent{
fun onViewPagerSwipeUp()
fun onViewPagerSwipeDown()
}
}
Adapter: Now takes a list of objects (Timers in this case)
class TimerSlidePagerAdapter(fragmentManager: FragmentManager, private val timers: List<TimerEntity>?):
FragmentStatePagerAdapter(fragmentManager){
override fun getItem(position: Int): Fragment {
if (count > 0) {
return makeTimerFragment(position)
}
return NoDataFragment()
}
override fun getCount(): Int = timers?.size ?: 0
//Internal Functions
private fun makeTimerFragment(position: Int): Fragment {
timers ?: return NoDataFragment()
val timeInMS = timers[position].timeInMS
return if (timeInMS > 0) {
TimerFragment.newInstance(timeInMS)
} else {
NoDataFragment()
}
}
}
Activity Implementation:
private fun setupPagerAdapter() {
val pagerAdapter = TimerSlidePagerAdapter(
fragmentManager = supportFragmentManager,
timers = viewModel?.timers?.value)
with(pager){
adapter = pagerAdapter
timerViewPagerEventListener = this#MainActivity
}
}
private fun observeTimers(){
viewModel.timers.observe(this, Observer {timerList->
timerList ?: Log.e(this.javaClass.simpleName, "List of timers is null")
.also {return#Observer}
setupPagerAdapter()
})
}
In my case I want to open PopupWindow by long press on ViewHolder item and process motion event in this window without removing finger. How can I achieve this?
I trying to open CustomPopupWindow by follow:
override fun onBindViewHolder(holder: Item, position: Int) {
val item = items[position]
holder.bindView(testItem)
holder.itemView.view.setOnLongClickListener {
val inflater = LayoutInflater.from(parent?.context)
val view = inflater.inflate(R.layout.popup_window, null)
val popupMenu = CustomPopupWindow(view, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
popupMenu.elevation = 5f
popupMenu.showAsDropDown(holder.itemView.view)
true
}
}
and after that disable scrolling in RecyclerView:
class CustomLayoutManager(context: Context) : LinearLayoutManager(context) {
var scrollEnabled: Boolean = true
override fun canScrollVertically(): Boolean {
return scrollEnabled
}
}
Here my CustomPopupWindow:
class CustomPopupWindow(contentView: View?, width: Int, height: Int) : PopupWindow(contentView, width, height), View.OnTouchListener {
init {
contentView?.setOnTouchListener(this)
setTouchInterceptor(this)
}
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
Log.i("Touch", "Touch")
}
MotionEvent.ACTION_MOVE -> {
Log.i("Touch", "Event {${event.x}; ${event.y}}")
}
MotionEvent.ACTION_UP-> {
Log.i("Touch", "Up")
}
}
return true
}
}
In this case onTouch() event never called in CustomPopupWindow only if I remove finger and tap again.
Thanks advance!
SOLVED
I solved this by adding a touch listener to the anchor view:
holder.itemView.view.setOnLongClickListener {
val inflater = LayoutInflater.from(parent?.context)
val view = inflater.inflate(R.layout.popup_window, null)
val popupMenu = CustomPopupWindow(view, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
popupMenu.elevation = 5f
it.setOnTouchListener(popupMenu) // solution
popupMenu.showAsDropDown(it)
true
}
Thanks #Brucelet
If you can refactor to using a PopupMenu, then I think PopupMenu.getDragToOpenListener() will do what you want. Similar for ListPopupWindow.createDragToOpenListener().
You could also look at the implementation of those methods for inspiration in creating your own.