Поиск по этому блогу

суббота, 6 июля 2019 г.

PopupWindow который указывает на view вызывающий его

Случается так что приходится делать выпадающее меню которое указывает на вью которое его вызвало и в нем выводить какие-то данные красиво, собственно я недавно столкнулся с такой штукой и теперь хочу поделиться с тем как я это сделал. 

У нас будет вообще элементарный пример, будет кнопка которая будет бегать по экране по клику на другую кнопку, а кнопка которая перемещается по экрану, по клику, будет вызывать диалог прицепленых к данной кнопке.



Никакие библиотеки нам в этот раз не понадобятся, никакие расширения дополнительные тоже. Использовать мы будем стандартный ListView для создания списка и обычный ArrayAdapter для написания адаптера для списка. Единственное что нам понадобится так это минимальная версия проекта minSdkVersion 19, так как для использования showAsDropDown() нужно иметь минимальный SDK 19.

Для начала нам нужно прописать ресурсы. Нам нужен будет фон для нашего PopupWindow. Его мы создадим в drawable, так как мы будем создавать эту вью программно, нам придется его присваивать не как цвет фона, а как Drawable.

colors.xml
<color name="brown_dark">#2C2935</color>
<color name="gray_dark">#4b4b4b</color>

Будет два основных цвета, серый для разделителя и фоновый коричневый.

background_popup_style.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" >
    <item>
        <shape android:shape="rectangle" android:bottom="0dp" android:left="20dp" android:right="20dp" android:top="0dp" >
            <solid android:color="@android:color/transparent"/>
            <padding android:bottom="0dp" android:left="20dp" android:right="20dp" android:top="0dp" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <corners android:radius="10dp" />
            <solid android:color="@color/brown_dark"/>
        </shape>
    </item>
</layer-list>

Тут мы просто рисуем бекграунд и заполняем его цветом с паддингами, и закругляем углы для визуальной красивости.

PointerPopupWindow.kt
import android.content.Context
import android.graphics.Rect
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupWindow
import dajver.com.pointerpopupwindowexample.R

class PointerPopupWindow private constructor(context: Context, width: Int, height: Int) : PopupWindow(width, height) {

    private val mContainer: LinearLayout
    private val mAnchorImage: ImageView
    private val mContent: FrameLayout
    private var mAlignMode = AlignMode.DEFAULT

    constructor(context: Context, width: Int) : this(context, width, ViewGroup.LayoutParams.WRAP_CONTENT) {}

    init {
        if (width < 0) {
            throw RuntimeException("You must specify the window width explicitly(do not use WRAP_CONTENT or MATCH_PARENT)!!!")
        }

        mContainer = LinearLayout(context)
        mContainer.orientation = LinearLayout.VERTICAL
        mAnchorImage = ImageView(context)
        mContent = FrameLayout(context)

        setBackgroundDrawable(context.resources.getDrawable(R.drawable.background_popup_style))
        isOutsideTouchable = true
        isFocusable = true
    }

    fun setAlignMode(mAlignMode: AlignMode) {
        this.mAlignMode = mAlignMode
    }

    fun setPointerImageRes(res: Int) {
        mAnchorImage.setImageResource(res)
    }

    override fun setContentView(contentView: View?) {
        if (contentView != null) {
            mContainer.removeAllViews()
            mContainer.addView(mAnchorImage, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
            mContainer.addView(mContent, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
            mContent.addView(contentView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
            super.setContentView(mContainer)
        }
    }

    override fun setBackgroundDrawable(background: Drawable) {
        mContent.setBackgroundDrawable(background)
        super.setBackgroundDrawable(ColorDrawable())
    }

    fun showAsPointer(anchor: View) {
        showAsPointer(anchor, 0,
            Y_OFFSET_FROM_THE_ANCHOR
        )
    }

    private fun showAsPointer(anchor: View, xOffset: Int, yOffset: Int) {
        var xOffset = xOffset
        val displayFrame = Rect()
        anchor.getWindowVisibleDisplayFrame(displayFrame)
        val displayFrameWidth = displayFrame.right - displayFrame.left

        val loc = IntArray(2)
        anchor.getLocationInWindow(loc)

        if (mAlignMode == AlignMode.AUTO_OFFSET) {
            val offCenterRate = (displayFrame.centerX() - loc[0]) / displayFrameWidth.toFloat()
            xOffset = ((anchor.width - width) / 2 + offCenterRate * width / 2).toInt()
        } else if (mAlignMode == AlignMode.CENTER_FIX) {
            xOffset = (anchor.width - width) / 2
        }

        val left = loc[0] + xOffset
        val right = left + width

        if (right > displayFrameWidth) {
            xOffset = displayFrameWidth - width - loc[0]
        }

        if (left < displayFrame.left) {
            xOffset = displayFrame.left - loc[0]
        }

        computePointerLocation(anchor, xOffset)
        super.showAsDropDown(anchor, xOffset, yOffset, Gravity.CENTER)
    }

    private fun computePointerLocation(anchor: View, xOffset: Int) {
        val aw = anchor.width
        val dw = mAnchorImage.drawable.intrinsicWidth
        mAnchorImage.setPadding((aw - dw) / 2 - xOffset, 0, 0, 0)
    }

    enum class AlignMode {
        DEFAULT,
        CENTER_FIX,
        AUTO_OFFSET
    }

    companion object {
        private const val Y_OFFSET_FROM_THE_ANCHOR = -20
    }
}

— init — инициализируем все нужные нам параметры, создаем контейнер в который помещаем все нужные параметры и задаем фоновый цвет.
— setAlignMode() — указывает какой вид направления будет у вьюхи, DEFAULT, CENTER_FIX или AUTO_OFFSET. В зависимости от которых мы ниже будем расчитывать положение выпадашки на экране.
— setPointerImageRes() — указывает картинку которая будет использоваться как поинтер на вью к которой привязана выпадашка.
— setContentView() — добавляет все вьюхи на экран.
— showAsPointer(anchor: View) — устанавливает вью на которую будет указывать поинтер. image. Эта картинка кстати нам понадобится дальше, так что сохраните ее себе как ic_popup_pointer.png.
— showAsPointer(anchor: View, xOffset: Int, yOffset: Int) — же расширеная версия метода showAsPointer(anchor: View), так как этот метод вызывает текущий метод. В данном методе мы делаем некоторые расчеты для отображения выпадашки на экране, и проверяем что бы она не проваливалась за пределы экрана и выпадалшка находилась под вьюхой которая создает эту выпадашку. Так же в этом методе мы проверяем AlignMode, в зависимости от которого мы отображаем наш выпадающий вью.
— computePointerLocation() — задает паддинг что бы наша вьюха не прилипала к краю экрана когда отображается на экране.

UsersModel.kt
class UsersModel(internal var title: String, internal var description: String)

У нас будет моделька Users которая будет в себе хранить имя пользователя и контент «типа» который этот пользователь написал. Ничего нового не смог придумать, старею…

item_users.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:padding="20dp">

    <TextView android:text="TextView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/name"
            android:textStyle="bold"
              android:textColor="@android:color/white"/>

    <TextView android:text="TextView"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/content"
            android:layout_marginTop="10dp"
            android:textSize="18sp" 
              android:textColor="@android:color/white"/>

</LinearLayout>

Нарисуем два текстовых поля, в которых мы покажем юзера и его сообщение.

PointerPopupAdapter.kt
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import dajver.com.pointerpopupwindowexample.R
import dajver.com.pointerpopupwindowexample.adapter.model.UsersModel
import kotlinx.android.synthetic.main.item_users.view.*
import java.util.*

class PointerPopupAdapter(context: Context, private val usersModels: ArrayList<UsersModel>) : ArrayAdapter<UsersModel>(context, 0, usersModels) {

    override fun getCount(): Int {
        return usersModels.size
    }

    override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
        val view = LayoutInflater.from(context).inflate(R.layout.item_users, null)

        val holder = PopupTitleViewHolder(view)
        holder.bind(usersModels[position])

        return view
    }

    inner class PopupTitleViewHolder(private var view: View) {

        fun bind(usersModel: UsersModel) {
            view.name.text = usersModel.title
            view.content.text = usersModel.description
        }
    }
}

Тут самый обычный адаптер, в который мы передаем список UsersModel, и отображаем через PopupTitleViewHolder. 

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

    <Button
            android:text="Call popup"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/openPopup"
            android:layout_marginLeft="45dp"
            android:layout_marginTop="250dp"/>
    <Button
            android:text="Change button position"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:id="@+id/changeButtonPosition"/>
</RelativeLayout>

Будет знач у нас две кнопки, одна будет вызывать выпадашку, а вторая будет по нажатию перемещать нашу кнопку call popup рандомно по экрану.

MainActivity.kt
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.WindowManager
import android.widget.ListView
import android.widget.RelativeLayout
import androidx.appcompat.app.AppCompatActivity
import dajver.com.pointerpopupwindowexample.adapter.PointerPopupAdapter
import dajver.com.pointerpopupwindowexample.adapter.model.UsersModel
import dajver.com.pointerpopupwindowexample.popup.PointerPopupWindow
import kotlinx.android.synthetic.main.activity_main.*
import java.util.*

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        openPopup.setOnClickListener {
            val wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager
            val width = wm.defaultDisplay.width

            val popupWindow = PointerPopupWindow(this, width)
            popupWindow.setPointerImageRes(R.mipmap.ic_popup_pointer)
            popupWindow.setAlignMode(PointerPopupWindow.AlignMode.CENTER_FIX)

            val usersModels = ArrayList<UsersModel>()
            usersModels.add(UsersModel("John Wick", "Lorem Ipsum is simply."))
            usersModels.add(UsersModel("David Hasselhoff", "It is a long established fact."))
            usersModels.add(UsersModel("Elizabeth Shaw", "Contrary to popular belief."))

            val adapter = PointerPopupAdapter(this, usersModels)
            val listView = ListView(this)
            listView.divider = ColorDrawable(resources.getColor(R.color.gray_dark))
            listView.dividerHeight = 1
            listView.adapter = adapter
            popupWindow.contentView = listView
            popupWindow.showAsPointer(openPopup)
        }

        changeButtonPosition.setOnClickListener {
            val params = openPopup.layoutParams as RelativeLayout.LayoutParams
            params.setMargins((1..1099).random(), (1..499).random(), 10, 0)
            openPopup.layoutParams = params
        }
    }
}

В onCreate() мы по клику на openPopup показываем наш попап. Получаем ширину экрана, отправляем его в конструктор PointerPopupWindow, для того что бы тот при создании растягивал выпадашку во всю ширину экрана. Задаем картинку поинтера и устанавливаем что бы попап показывался под вьюхой но по середине экрана. Дальше мы создаем список с пользователям, отправляем этот список в адаптер, а дальше адаптер передаем в listView, а listView передаем в popupWindow что бы тот отображался внутри выпадашки. Ну и в showAsPointer задаем кнопку под которой будем показывать выпадашку.

Ну а по нажатию на changeButtonPosition — мы просто задаем margin рандомно от 1 до 1099 от верхней точки экрана и от 1 до 499 с левого края экрана.

Собственно и все. Последний абзац не сильно важный, но его нужно было объяснить :) 

Всем спасибо, все свободны.

Исходники:
GitHub

2 комментария:

  1. Глеб, отличный блог - с полными примерами, есть все импорты, xml-ки. Мне как новичку такое очень полезно. Зачастую "обучалки" страдают лаконичностью в примерах. Но когда важна кадая запятая, полный код - это лучше всего.

    ОтветитьУдалить
    Ответы
    1. Спасибо, стараюсь уже который год что бы и код был понятный и объяснения не доводили до истерики :)

      Удалить