airbnb / epoxy

Epoxy is an Android library for building complex screens in a RecyclerView
https://goo.gl/eIK82p
Apache License 2.0
8.51k stars 728 forks source link

[Question] Update multiple views separately by Click Listener #746

Closed longnguyencmg closed 5 years ago

longnguyencmg commented 5 years ago

Hello, I'm new to the library and I have an issue while implementing my example app. In general, I have a list view of model (using @EpoxyModelClass and @EpoxyAttribute manually). In each model, I have a button download and download_progress_view (updated by progress). When clicking on download button, then it will update progress of view in model with different values. I have problem with this, click on download then all model update progress instead of the clicked one. Do you have any suggestions?

Thanks for great library. Please have a look at relevant code below:

My Model

@EpoxyModelClass(layout = R.layout.view_holder_course_detail_chapter_item)
abstract class CourseDetailChapterModel : EpoxyModelWithHolder<CourseChapterViewHolder>() {

    @EpoxyAttribute
    lateinit var onClickListener: () -> Unit
    @EpoxyAttribute
    lateinit var courseChapter: CourseChapter
    @EpoxyAttribute
    lateinit var course: Course
    @EpoxyAttribute
    lateinit var purchaseState: PurchaseState
    @EpoxyAttribute
    lateinit var authState: AuthState
    @EpoxyAttribute
    lateinit var context: Context
    @EpoxyAttribute
    lateinit var onDownloadClick: () -> Unit
    @EpoxyAttribute
    var myProgress: Float = 0F

    @SuppressLint("SetTextI18n")
    override fun bind(holder: CourseChapterViewHolder) {
        var fileName = "${courseChapter.courseId}:${courseChapter.chapterNumber}:${courseChapter.version}.pdf"
        var fileExisted = FileUtils.checkFileExisted(context, fileName)

        holder.chapterDownloadProgress.color = Color.parseColor("#${course.colorPrimary}")
        holder.chapterTitleTextView.text = "${courseChapter.chapterNumber}. ${courseChapter.title}"
        if (courseChapter.currentlyPlaying) {
            holder.chapterTitleTextView.setTextColor((Color.parseColor("#${course.colorPrimary}")))
        } else {
            holder.chapterTitleTextView.setTextColor(Color.WHITE)
        }
        holder.chapterDurationTextView.text = millisecondsToString(courseChapter.duration)
        holder.chapterBackground.setOnClickListener { onClickListener.invoke() }
        holder.chapterDescriptionTextView.text = courseChapter.description
        if (courseChapter.isTrial || (purchaseState.purchased && authState.authed)) {
            holder.chapterLockIcon.visibility = View.GONE
        }

        if (fileExisted) {
            holder.chapterDownloadTextView.visibility = VISIBLE
            holder.chapterDownloadedIcon.visibility = VISIBLE
            holder.chapterDownloadTextView.text = context.getString(R.string.lesson_downloaded)
            holder.chapterDownloadProgress.progress = 0f
            holder.chapterDownloadTextView.setOnClickListener(null)
        } else {
                holder.chapterDownloadedIcon.visibility = GONE
                holder.chapterDownloadTextView.visibility = VISIBLE
                holder.chapterDownloadTextView.text = context.getString(R.string.lesson_download)
                holder.chapterDownloadTextView.setOnClickListener { onDownloadClick.invoke() }

                holder.chapterDownloadProgress.progress = myProgress
                if (myProgress > 0F) {
                    holder.chapterDownloadProgress.visibility = VISIBLE
                    holder.chapterDownloadTextView.visibility = GONE
                    holder.chapterDownloadedIcon.visibility = GONE

                    if (myProgress in 99F..100F) {
                        holder.chapterDownloadProgress.visibility = GONE
                        holder.chapterDownloadTextView.text = context.getString(R.string.lesson_downloaded)
                        holder.chapterDownloadTextView.visibility = VISIBLE
                        holder.chapterDownloadedIcon.visibility = VISIBLE
                    }
                }
        }
    }

    override fun unbind(holder: CourseChapterViewHolder) {
        super.unbind(holder)
        holder.chapterLockIcon.visibility = View.VISIBLE
        holder.chapterTitleTextView.setTextColor(Color.WHITE)
    }

    private fun millisecondsToString(millis: Long): String {
        val hours = millis / 60
        val minutes = millis % 60
        return if (hours > 0) {
            "$hours hr $minutes min"
        } else {
            "$minutes min"
        }
    }
}

class CourseChapterViewHolder : BaseEpoxyHolder() {

    val chapterBackground by bind<View>(R.id.course_chapter_item_background)
    val chapterTitleTextView by bind<TextView>(R.id.course_chapter_item_title_text_view)
    val chapterDurationTextView by bind<TextView>(R.id.course_chapter_item_duration_text_view)
    val chapterDescriptionTextView by bind<TextView>(R.id.course_chapter_description_text_view)
    val chapterLockIcon by bind<ImageView>(R.id.course_chapter_item_lock_image_view)
    val chapterDownloadTextView by bind<TextView>(R.id.download_title)
    val chapterDownloadedIcon by bind<ImageView>(R.id.downloaded_icon)
    val chapterDownloadProgress by bind<CircularProgressBar>(R.id.download_status)

}

My controller

class CourseDetailChaptersController(private val course: Course, private val context: Context) : Typed4EpoxyController<Boolean, List<CourseChapter>, Boolean, Boolean>() {

    var chaptersListener: CourseDetailChaptersListener? = null

    var downloadListener: CourseDetailChaptersDownloadListener? = null

    private var myProgress: Float = 0F

    fun setProgress(progress: Float) {
        this.myProgress = progress
    }

    override fun buildModels(loading: Boolean, chapters: List<CourseChapter>, coursePurchased: Boolean, authenticated: Boolean) {
        if (loading) {
            courseDetailLoading {
                id("loading")
            }
            return
        }
        if (chapters.isNotEmpty()) {
            courseDetailEmptySpace {
                id("empty_space")
            }
            for (chapter in chapters.sortedBy { it.chapterNumber }) {
                courseDetailChapter {
                    context(context)
                    id(chapter.id)
                    courseChapter(chapter)
                    purchaseState(PurchaseState(coursePurchased))
                    course(course)
                    authState(AuthState(authenticated))
                    onClickListener{
                        chaptersListener?.onChapterClicked(chapter)
                    }
                    onDownloadClick {
                        downloadListener?.onDownloadClicked(chapter)
                    }
                    myProgress(myProgress)
                }
            }
        }
    }

}

Handle onDownloadClicked

private fun downloadFile(chapter: CourseChapter, downloadUrl: String) {
        val myDirectory = File(context!!.filesDir.path + "/MyResources/")
        var filePath: String
        Fuel.download(downloadUrl)
                .fileDestination { response, url ->
                    myDirectory.mkdir()
                    filePath = File(myDirectory.absolutePath, "${chapter.courseId}:${chapter.chapterNumber}:${chapter.version}.pdf").absolutePath
                    File(filePath)
                }
                .progress { readBytes, totalBytes ->
                    val progress = readBytes.toFloat() / totalBytes.toFloat() * 100
                    Timber.i("Bytes downloaded $readBytes / $totalBytes ($progress %)")
                    controller.setProgress(progress = progress)
                    handleDataState(chaptersViewModel?.getChapters()!!.value!!)
                }
                .response { result ->
                    Timber.i(result.toString())
                    if (result.toString().contains("Success")) {
                        activity!!.runOnUiThread {
                            Toast.makeText(context, "Downloaded file ${chapter.courseId}:${chapter.chapterNumber}:${chapter.version}.pdf", Toast.LENGTH_LONG).show()
                        }
                    }
                }
    }
longnguyencmg commented 5 years ago

@elihart Can u please help me here?

elihart commented 5 years ago
fun setProgress(progress: Float) {
        this.myProgress = progress
    }

this doesn't make the models get built again - you need to call requestModelBuild there.

also, you shouldn't be passing in context like this

@EpoxyAttribute
    lateinit var context: Context

get it off of your viewholder views instead

longnguyencmg commented 5 years ago

@elihart, thank you for your response.

Actually, I tried requestModelBuild() before and got exception You cannot call 'requestModelBuild' directly. Call 'setData' instead to trigger a model refresh with new data. I'm using Typed4EpoxyController

So when I handle click Download action, I have to update progress value into the controller. And then call setData again in function handleDataState (below)

private fun handleDataState(resource: Resource<List<CourseChapterView>>) {
        when (resource.status) {
            ResourceState.SUCCESS -> {
                resource.data?.let { chapterViews ->
                    val chapters = chapterViews.map { courseChapterViewMapper.mapFromView(it) }
                    controller.setData(false, chapters, isPurchased, isAuthenticated)
                    checkIfChapterIsPlaying()
                }
            }
            ResourceState.LOADING -> {
                controller.setData(true, emptyList(), isPurchased, isAuthenticated)
            }
            ResourceState.ERROR -> {
                Timber.d(resource.message)
            }
        }
    }

And then, the setData function update progress for all models at the same time instead of the selected item. That's what I unexpected.

What I try to do here is when I click on button 'Download' on each row, then the Model just update the progress itself separately.

elihart commented 5 years ago

Oh, I see what you are saying - this is because your data structure isn't set up properly - you are using the same progress value for all models.

Epoxy always rebuilds all models - get in the mindset of thinking of your entire data set mapping to the models, and all models are rebuilt from the data.

you need to change your data structure to completely represent your models. The MvRx library is good for that and works very nicely with epoxy, but you can also just use a data class. You shouldn't be using the typed adapter - it's messy when you have 4 parameters, plus you are setting data via the progress setter.

anyway, for your specific problem, you need a map of chapter to progress

val progressMap = mutableMapOf<Chapter, Float>()
fun setProgress(chapter: Chapter, progress: Float) = progressMap[chapter] = progress

and when you build models, grab the right progress for the chapter

longnguyencmg commented 5 years ago

Thank you very much for your solution. It works well now. Cheers :)