androidkotlinandroid-jetpack-composeandroid-roomhorizontal-pager

how to add swipe detection to a HorizontalPager?


I am still new to coding in kotlin for android and I am having trouble with a function for my child Naming Application. This particular function is meant to allow users to swipe left and right on ChildNames, swipe left for dislike ,right for like, the swipes are then meant to be recorded by the data class 'SwipedName' into a room database tablecalled 'SwipedNames'. however currently no data is being saved to the table as well as swiping left moves the user to the previous name rather that to the next name.

package com.examples.childrennaming

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.examples.childrennaming.ui.theme.ChildrenNamingTheme

class SwipeActivity : ComponentActivity() {
    private lateinit var database: AppDatabase //Declares a private lateinit variable database of type AppDatabase for late state intialisation

    override fun onCreate(savedInstanceState: Bundle?) { //onCreate method is called when the activity is first created to perform intial set up
        super.onCreate(savedInstanceState)

        val currentUserId = intent.getIntExtra("CURRENT_USER_ID", -1)

        database = (application as MyApp).database //Initialises the database property using the application context

        val nameDao = database.nameDao() //Retrieves the DAO (Data Access Object) from the database
        val repository = NameRepository(nameDao) //Creates an instance of NameRepository with the DAO
        val viewModel: NameViewModel by viewModels { NameViewModelFactory(repository) } //Initializes a NameViewModel instance using a NameViewModel factory that provides the repository


        val swipedNameDao = database.swipedNameDao() //Retrieves the swipedNameDao from the database
        val swipedNameRepository = SwipedNameRepository(swipedNameDao) //Creates an instance of SwipedNameRepository with the swipedNameDao



        setContent { //Sets the content view of the activity using Jetpack Compose
            ChildrenNamingTheme { //Applies a theme to the Compose UI.
                val names by viewModel.allNames.observeAsState(emptyList()) //Observes allNames LiveData from the ViewModel and collects its state, to give an empty list as the initial state.
                NamePagerScreen(names, currentUserId, swipedNameRepository) //Calls the composable function NamePagerScreen with the list of names.
            }
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable //Marks this as a composable function
fun NamePagerScreen(names: List<Name>, currentUserId: Int, swipedNameRepository: SwipedNameRepository) { //Defines a composable function NamePagerScreen that takes a list of Name objects, currentUserId  and SwipedNameRepository as a parameter.
    val pagerState = rememberPagerState( //Creates a rememberPagerState instance to manage the pager state
        initialPage = 0,// Sets the initial page to 0, shows first name in the table first
        initialPageOffsetFraction = 0f // Sets the initial page offset fraction to 0
    ) {
        names.size //Sets the total number of pages based on the size of the names lis
    }
    val swipedNameViewModel: SwipedNameViewModel = viewModel( //Creates a SwipedNameViewModel instance using the viewModel() function.
        factory = SwipedNameViewModelFactory(swipedNameRepository) //Provides the SwipedNameRepository as a parameter to the factory.
    )

    Box(modifier = Modifier //Creates a box layout for swipe detection
        .fillMaxSize() //Fills the entire available space
        .pointerInput(Unit) { //Enables pointer input for the box
            detectHorizontalDragGestures { change, dragAmount -> //Detects horizontal drag gestures
                val isRightSwipe = dragAmount > 0 //Determines if the swipe is to the right
                val swipedName = SwipedName( //Creates a new SwipedName object with the provided parameters
                    userId = currentUserId,
                    nameId = names[pagerState.currentPage].nameId,
                    isRightSwipe = isRightSwipe //Sets the isRightSwipe property based on the swipe direction
                )
                swipedNameViewModel.insert(swipedName) //Inserts the swipedName into the database using the SwipedNameViewModel
                change.consume() //Consumes the change event to prevent further processing
            }
        }
    ) {
        HorizontalPager( //Creates a horizontal pager layout

            state = pagerState //Creates a horizontal pager with the given state.
        ) { page ->
            val name = names[page] //Gets the name value corresponding to the current page.
            val background = when (name.gender) { //Sets the background resource based on the gender of the name
                "Girl" -> R.drawable.pink //if gender is "Girl" sets background a pink.jpg
                "Boy" -> R.drawable.blue //if gender is "Boy" sets background a pink.jpg
                else -> R.drawable.default_background //else sets background a default_background.jpg
            }

            Box(modifier = Modifier.fillMaxSize()) {//box layout for Name and gender text
                Image( //Displays background image
                    painter = painterResource(id = background), //sets background image with the specified painter resource based on gender
                    contentDescription = null,
                    contentScale = ContentScale.Crop,//crops scale of box
                    modifier = Modifier.fillMaxSize()
                )
                Box( //style of the box set to semi transparent black
                    modifier = Modifier //box modifier
                        .align(Alignment.Center)
                        .background(Color(0x80000000))
                        .fillMaxWidth()
                        .padding(16.dp)
                ) {
                    Text( //style for name and gender text
                        text = "${name.childFirstName} - ${name.gender}", //sets name and gender text
                        style = MaterialTheme.typography.headlineMedium,
                        color = Color.White,
                        modifier = Modifier.align(Alignment.Center)
                    )
                }
            }
        }




    }



}

Here is the data class for 'SwipeName'

@Entity(tableName = "swiped_names")
data class SwipedName(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val userId: Int,
    val nameId: Int,
    val isRightSwipe: Boolean
)

if any more information is needed for clarification, please let me know.

I have since tried moving the detectHorizontalDragGestures to attach it directly to the HorizontalPager, this did yield some results, as it did save the swipes correctly into the table, however, the other data such as the userID is wrong, it also broke the actual swiping between pages feature and is stuck on the initial page.

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.room.Room
import com.examples.childrennaming.ui.theme.ChildrenNamingTheme
import kotlinx.coroutines.Dispatchers
import kotlin.math.abs

class SwipeActivity : ComponentActivity() {
    private lateinit var database: AppDatabase //Declares a private lateinit variable database of type AppDatabase for late state intialisation

    override fun onCreate(savedInstanceState: Bundle?) { //onCreate method is called when the activity is first created to perform intial set up
        super.onCreate(savedInstanceState)

        val currentUserId = intent.getIntExtra("CURRENT_USER_ID", -1)

        database = (application as MyApp).database //Initialises the database property using the application context

        val nameDao = database.nameDao() //Retrieves the DAO (Data Access Object) from the database
        val repository = NameRepository(nameDao) //Creates an instance of NameRepository with the DAO
        val viewModel: NameViewModel by viewModels { NameViewModelFactory(repository) } //Initializes a NameViewModel instance using a NameViewModel factory that provides the repository


        val swipedNameDao = database.swipedNameDao() //Retrieves the swipedNameDao from the database
        val swipedNameRepository = SwipedNameRepository(swipedNameDao) //Creates an instance of SwipedNameRepository with the swipedNameDao



        setContent { //Sets the content view of the activity using Jetpack Compose
            ChildrenNamingTheme { //Applies a theme to the Compose UI.
                val names by viewModel.allNames.observeAsState(emptyList()) //Observes allNames LiveData from the ViewModel and collects its state, to give an empty list as the initial state.
                NamePagerScreen(names, currentUserId, swipedNameRepository) //Calls the composable function NamePagerScreen with the list of names.
            }
        }
    }
}

@OptIn(ExperimentalFoundationApi::class)
@Composable //Marks this as a composable function
fun NamePagerScreen(names: List<Name>, currentUserId: Int, swipedNameRepository: SwipedNameRepository) { //Defines a composable function NamePagerScreen that takes a list of Name objects, currentUserId  and SwipedNameRepository as a parameter.
    val pagerState = rememberPagerState( //Creates a rememberPagerState instance to manage the pager state
        initialPage = 0,// Sets the initial page to 0, shows first name in the table first
        initialPageOffsetFraction = 0f // Sets the initial page offset fraction to 0
    ) {
        names.size //Sets the total number of pages based on the size of the names lis
    }
    val swipedNameViewModel: SwipedNameViewModel = viewModel( //Creates a SwipedNameViewModel instance using the viewModel() function.
        factory = SwipedNameViewModelFactory(swipedNameRepository) //Provides the SwipedNameRepository as a parameter to the factory.
    )

    HorizontalPager(state = pagerState) { page ->
        val name = names[page]
        val background = when (name.gender) {
            "Girl" -> R.drawable.pink
            "Boy" -> R.drawable.blue
            else -> R.drawable.default_background
        }

        Box(
            modifier = Modifier
                .fillMaxSize()
                .pointerInput(Unit) {
                    detectHorizontalDragGestures { change, dragAmount ->
                        val isSignificantSwipe = abs(dragAmount) > 16 // Define a threshold

                        if (isSignificantSwipe) {
                            val isRightSwipe = dragAmount > 0
                            val swipedName = SwipedName(
                                userId = currentUserId,
                                nameId = name.nameId,
                                isRightSwipe = isRightSwipe
                            )
                            Log.d("SwipeActivity", "Detected swipe: isRightSwipe=$isRightSwipe, swipedName=$swipedName")
                            swipedNameViewModel.insert(swipedName)
                            change.consume() // Consume for significant swipes
                        }
                        
                    }
                }
        ) {
            Image(
                painter = painterResource(id = background),
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier.fillMaxSize()
            )
            Box(
                modifier = Modifier
                    .align(Alignment.Center)
                    .background(Color(0x80000000))
                    .fillMaxWidth()
                    .padding(16.dp)
            ) {
                Text(
                    text = "${name.childFirstName} - ${name.gender}",
                    style = MaterialTheme.typography.headlineMedium,
                    color = Color.White,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
    }
}

Solution

  • You don't need additional drag gesture to check if user swiped in any direction. You can use pagerState. settledPage to check slide events inside Launchedeffect with snapshotFlow as

    val pagerState = rememberPagerState {
        // page count
    }
    
    var userScrolled by remember {
        mutableStateOf(false)
    }
    
    LaunchedEffect(pagerState.isScrollInProgress) {
        if (pagerState.isScrollInProgress) {
            userScrolled = true
        }
    }
    
    LaunchedEffect(pagerState) {
        snapshotFlow { pagerState.settledPage }.collect {
            if (pagerState.targetPage == pagerState.settledPage && userScrolled) {
                println("Swiped to ${pagerState.currentPage}")
            }
        }
    }
    

    Or like this if you consider swiping completed even gesture hasn't finished yet. User might half scroll to from page 0 to page 1 and not swipe back to 0 and it would still count as swiped to page 1 with this alternative. First one is more reliable because it expect animation to finish target to be equal to settle page and since both are equal at the beginning you need to check if user ever swiped or reset it if needed.

    LaunchedEffect(pagerState) {
        snapshotFlow { pagerState.currentPage }.collect {
            if (pagerState.currentPage != pagerState.settledPage) {
                println("Swiped to ${pagerState.currentPage}")
            }
        }
    }