EmojiPickerView.kt

/*
 * Copyright 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.emoji2.emojipicker

import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.util.Consumer
import androidx.core.view.ViewCompat
import androidx.emoji2.emojipicker.EmojiPickerConstants.DEFAULT_MAX_RECENT_ITEM_ROWS
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

/**
 * The emoji picker view that provides up-to-date emojis in a vertical scrollable view with a
 * clickable horizontal header.
 */
class EmojiPickerView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) :
    FrameLayout(context, attrs, defStyleAttr) {
    /**
     * The number of rows of the emoji picker.
     *
     * Default value([EmojiPickerConstants.DEFAULT_BODY_ROWS]: 7.5) will be used if emojiGridRows
     * is set to non-positive value. Float value indicates that we will display partial of the last
     * row and have content down, so the users get the idea that they can scroll down for more
     * contents.
     * @attr ref androidx.emoji2.emojipicker.R.styleable.EmojiPickerView_emojiGridRows
     */
    var emojiGridRows: Float = EmojiPickerConstants.DEFAULT_BODY_ROWS
        set(value) {
            field = if (value > 0) value else EmojiPickerConstants.DEFAULT_BODY_ROWS
            // this step is to ensure the layout refresh when emojiGridRows is reset
            if (isLaidOut) {
                showEmojiPickerView()
            }
        }

    /**
     * The number of columns of the emoji picker.
     *
     * Default value([EmojiPickerConstants.DEFAULT_BODY_COLUMNS]: 9) will be used if
     * emojiGridColumns is set to non-positive value.
     * @attr ref androidx.emoji2.emojipicker.R.styleable.EmojiPickerView_emojiGridColumns
     */
    var emojiGridColumns: Int = EmojiPickerConstants.DEFAULT_BODY_COLUMNS
        set(value) {
            field = if (value > 0) value else EmojiPickerConstants.DEFAULT_BODY_COLUMNS
            // this step is to ensure the layout refresh when emojiGridColumns is reset
            if (isLaidOut) {
                showEmojiPickerView()
            }
        }

    private val stickyVariantProvider = StickyVariantProvider(context)
    private val scope = CoroutineScope(EmptyCoroutineContext)

    private var recentEmojiProvider: RecentEmojiProvider = DefaultRecentEmojiProvider(context)
    private val recentItems: MutableList<EmojiViewData> = mutableListOf()
    private lateinit var recentItemGroup: ItemGroup

    private lateinit var emojiPickerItems: EmojiPickerItems
    private lateinit var bodyAdapter: EmojiPickerBodyAdapter

    private var onEmojiPickedListener: Consumer<EmojiViewItem>? = null

    init {
        val typedArray: TypedArray =
            context.obtainStyledAttributes(attrs, R.styleable.EmojiPickerView, 0, 0)
        emojiGridRows = typedArray.getFloat(
            R.styleable.EmojiPickerView_emojiGridRows,
            EmojiPickerConstants.DEFAULT_BODY_ROWS
        )
        emojiGridColumns = typedArray.getInt(
            R.styleable.EmojiPickerView_emojiGridColumns,
            EmojiPickerConstants.DEFAULT_BODY_COLUMNS
        )
        typedArray.recycle()

        scope.launch(Dispatchers.IO) {
            val load = launch { BundledEmojiListLoader.load(context) }
            refreshRecentItems()
            load.join()

            withContext(Dispatchers.Main) {
                showEmojiPickerView()
            }
        }
    }

    private fun createEmojiPickerBodyAdapter(
        emojiPickerItems: EmojiPickerItems,
    ): EmojiPickerBodyAdapter {
        return EmojiPickerBodyAdapter(
            context,
            emojiGridColumns,
            emojiGridRows,
            stickyVariantProvider,
            emojiPickerItems,
            onEmojiPickedListener = { emojiViewItem ->
                onEmojiPickedListener?.accept(emojiViewItem)

                scope.launch {
                    recentEmojiProvider.recordSelection(emojiViewItem.emoji)
                    refreshRecentItems()
                }
            }
        )
    }

    private fun showEmojiPickerView() {
        emojiPickerItems = EmojiPickerItems(buildList {
            add(ItemGroup(
                R.drawable.quantum_gm_ic_access_time_filled_vd_theme_24,
                CategoryTitle(context.getString(R.string.emoji_category_recent)),
                recentItems,
                forceContentSize = DEFAULT_MAX_RECENT_ITEM_ROWS * emojiGridColumns,
                emptyPlaceholderItem = PlaceholderText(
                    context.getString(R.string.emoji_empty_recent_category)
                )
            ).also { recentItemGroup = it })

            for ((headerIconId, name, emojis) in BundledEmojiListLoader.getCategorizedEmojiData()) {
                add(
                    ItemGroup(
                        headerIconId,
                        CategoryTitle(name),
                        emojis.map {
                            EmojiViewData(stickyVariantProvider[it.emoji])
                        },
                    )
                )
            }
        })

        val bodyLayoutManager = GridLayoutManager(
            context,
            emojiGridColumns,
            LinearLayoutManager.VERTICAL,
            /* reverseLayout = */ false
        ).apply {
            spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
                override fun getSpanSize(position: Int): Int {
                    return if (emojiPickerItems.getBodyItem(position).occupyEntireRow)
                        emojiGridColumns
                    else 1
                }
            }
        }

        val headerAdapter =
            EmojiPickerHeaderAdapter(context, emojiPickerItems, onHeaderIconClicked = {
                bodyLayoutManager.scrollToPositionWithOffset(
                    emojiPickerItems.firstItemPositionByGroupIndex(it),
                    0
                )
            })

        // clear view's children in case of resetting layout
        super.removeAllViews()
        with(inflate(context, R.layout.emoji_picker, this)) {
            // set headerView
            ViewCompat.requireViewById<RecyclerView>(this, R.id.emoji_picker_header).apply {
                layoutManager =
                    object : LinearLayoutManager(
                        context,
                        HORIZONTAL,
                        /* reverseLayout = */ false
                    ) {
                        override fun checkLayoutParams(lp: RecyclerView.LayoutParams): Boolean {
                            lp.width =
                                (width - paddingStart - paddingEnd) / emojiPickerItems.numGroups
                            return true
                        }
                    }
                adapter = headerAdapter
            }

            // set bodyView
            ViewCompat.requireViewById<RecyclerView>(this, R.id.emoji_picker_body).apply {
                layoutManager = bodyLayoutManager
                adapter = createEmojiPickerBodyAdapter(emojiPickerItems).also { bodyAdapter = it }
                addOnScrollListener(object : RecyclerView.OnScrollListener() {
                    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                        super.onScrolled(recyclerView, dx, dy)
                        val position =
                            bodyLayoutManager.findFirstCompletelyVisibleItemPosition()
                        headerAdapter.selectedGroupIndex =
                            emojiPickerItems.groupIndexByItemPosition(position)
                    }
                })
                // Disable item insertion/deletion animation. This keeps view holder unchanged when
                // item updates.
                itemAnimator = null
                setRecycledViewPool(RecyclerView.RecycledViewPool().apply {
                    setMaxRecycledViews(
                        ItemType.EMOJI.ordinal,
                        EmojiPickerConstants.EMOJI_VIEW_POOL_SIZE
                    )
                })
            }
        }
    }

    private suspend fun refreshRecentItems() {
        val recent = recentEmojiProvider.getRecentEmojiList()
        recentItems.clear()
        recentItems.addAll(recent.map {
            EmojiViewData(
                it,
                updateToSticky = false,
            )
        })
    }

    /**
     * This function is used to set the custom behavior after clicking on an emoji icon. Clients
     * could specify their own behavior inside this function.
     */
    fun setOnEmojiPickedListener(onEmojiPickedListener: Consumer<EmojiViewItem>?) {
        this.onEmojiPickedListener = onEmojiPickedListener
    }

    fun setRecentEmojiProvider(recentEmojiProvider: RecentEmojiProvider) {
        this.recentEmojiProvider = recentEmojiProvider

        scope.launch {
            refreshRecentItems()
            if (::emojiPickerItems.isInitialized) {
                val range = emojiPickerItems.groupRange(recentItemGroup)
                withContext(Dispatchers.Main) {
                    bodyAdapter.notifyItemRangeChanged(range.first, range.last + 1)
                }
            }
        }
    }

    /**
     * The following functions disallow clients to add view to the EmojiPickerView
     *
     * @param child the child view to be added
     * @throws UnsupportedOperationException
     */
    override fun addView(child: View?) {
        if (childCount > 0)
            throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
        else super.addView(child)
    }

    /**
     * @param child
     * @param params
     * @throws UnsupportedOperationException
     */
    override fun addView(child: View?, params: ViewGroup.LayoutParams?) {
        if (childCount > 0)
            throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
        else super.addView(child, params)
    }

    /**
     * @param child
     * @param index
     * @throws UnsupportedOperationException
     */
    override fun addView(child: View?, index: Int) {
        if (childCount > 0)
            throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
        else super.addView(child, index)
    }

    /**
     * @param child
     * @param index
     * @param params
     * @throws UnsupportedOperationException
     */
    override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) {
        if (childCount > 0)
            throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
        else super.addView(child, index, params)
    }

    /**
     * @param child
     * @param width
     * @param height
     * @throws UnsupportedOperationException
     */
    override fun addView(child: View?, width: Int, height: Int) {
        if (childCount > 0)
            throw UnsupportedOperationException(EmojiPickerConstants.ADD_VIEW_EXCEPTION_MESSAGE)
        else super.addView(child, width, height)
    }

    /**
     * The following functions disallow clients to remove view from the EmojiPickerView
     * @throws UnsupportedOperationException
     */
    override fun removeAllViews() {
        throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
    }

    /**
     * @param child
     * @throws UnsupportedOperationException
     */
    override fun removeView(child: View?) {
        throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
    }

    /**
     * @param index
     * @throws UnsupportedOperationException
     */
    override fun removeViewAt(index: Int) {
        throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
    }

    /**
     * @param child
     * @throws UnsupportedOperationException
     */
    override fun removeViewInLayout(child: View?) {
        throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
    }

    /**
     * @param start
     * @param count
     * @throws UnsupportedOperationException
     */
    override fun removeViews(start: Int, count: Int) {
        throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
    }

    /**
     * @param start
     * @param count
     * @throws UnsupportedOperationException
     */
    override fun removeViewsInLayout(start: Int, count: Int) {
        throw UnsupportedOperationException(EmojiPickerConstants.REMOVE_VIEW_EXCEPTION_MESSAGE)
    }
}