Как и когда правильно инициализировать унаследованные конструкторы пользовательских представлений? - PullRequest
0 голосов
/ 03 марта 2020

Я пытаюсь создать пользовательскую иерархию представлений для Android в Kotlin, и вот чего я хотел бы достичь:

  • Неинстанцируемый класс TileView, который определяет пользовательское представление с меткой, некоторым текстовым содержимым и значком - в основном определяет макет плитки.
  • Неинстанцируемый класс TextInputView, расширяющий TileView, который открывает диалоговое окно, позволяющее для ввода текста в представление текстового содержимого родителя.
  • Реализуемый класс CommentView, расширяющий TextInputView, чтобы позволить пользователю написать комментарий.

view_tile.xml

Эта иерархия позволила бы мне реализовать другие пользовательские представления, такие как NameView или PhoneNoView, простирающиеся от TextInputView, или, возможно, DateView с использованием DatePickerDialog для ввода даты и отобразить его в TileView.

Я пробовал разные подходы, но все заканчиваются исключениями времени выполнения или предупреждениями IDE. Когда я пытаюсь надуть макет в init {} -методе TileView, я получаю следующую ошибку:

Утечка 'this' в конструкторе неконечного класса

Вместо этого я реализовал свой собственный init() -метод, который можно вызывать и перезаписывать из дочернего представления. Пожалуйста, обратите внимание на мою пояснительную документацию по классу, которая, мы надеемся, может быть удалена, если я найду лучшую / более простую стратегию реализации.

view_tile. xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="?android:attr/selectableItemBackground"
    android:minHeight="@dimen/two_line_tile_height">

    <ImageView
        android:id="@+id/tileIconView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="@dimen/activity_horizontal_margin"
        android:layout_marginTop="24dp"
        android:contentDescription="@string/cd_icon"
        android:src="@drawable/ic_sd_gray_24dp" />

    <TextView
        android:id="@+id/tileLabelView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginStart="@dimen/content_horizontal_margin"
        android:layout_marginTop="@dimen/activity_vertical_margin"
        android:layout_marginEnd="@dimen/activity_horizontal_margin"
        android:ellipsize="end"
        android:maxLines="1"
        android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
        tools:text="Label" />

    <TextView
        android:id="@+id/tileContentView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/tileLabelView"
        android:layout_alignStart="@id/tileLabelView"
        android:layout_alignEnd="@id/tileLabelView"
        android:layout_marginBottom="@dimen/activity_vertical_margin"
        android:ellipsize="end"
        tools:hint="Content" />
    <!--
    Using singleLine even though it is deprecated,
    because we cannot change maxLines at runtime,
    while also using ellipsize. See this issue:
    https://issuetracker.google.com/issues/36950033
    -->

</RelativeLayout>

TileView.kt

import kotlinx.android.synthetic.main.view_tile.view.*

/**
 * This is a tile view to be shown in input forms throughout the app.
 * The TileView is meant to be extended, and only defines the presentation of the view.
 * Since the TileView is abstract and therefore not final, the init method must be called from the
 * init method of the child view, to inflate the root layout.
 * The views functionality should be implemented in a child view.
 */
abstract class TileView @JvmOverloads internal constructor(context: Context,
                                                           attrs: AttributeSet? = null,
                                                           @AttrRes defStyleAttr: Int = 0,
                                                           @StyleRes defStyleRes: Int = 0) :
        FrameLayout(context, attrs, defStyleAttr, defStyleRes), View.OnClickListener {

    interface OnContentChangedListener {
        fun onContentChanged(view: TileView, text: String?)
    }

    var onContentChangedListener: OnContentChangedListener? = null

    val label: String
        get() = tileLabelView.text.toString()

    val hint: String
        get() = tileContentView.hint.toString()

    var text: String?
        get() = tileContentView.text.toString()
        set(value) {
            if (value != tileContentView.text.toString()) {
                tileContentView.text = value
                onContentChangedListener?.onContentChanged(this, value)
            }
        }

    @CallSuper
    open fun init() {
        inflate(context, R.layout.view_tile, this)
        setOnClickListener(this)
    }

    fun isPopulated(): Boolean = !text.isNullOrBlank()

    protected fun setDrawableRes(@DrawableRes drawableRes: Int) {
        val drawable = ContextCompat.getDrawable(context, drawableRes)
        tileIconView.setImageDrawable(drawable)
    }

    protected fun setLabelRes(@StringRes label: Int) {
        tileLabelView.text = resources.getString(label)
    }

    protected fun setHintRes(@StringRes hint: Int) {
        tileContentView.hint = resources.getString(hint)
    }

    protected fun setSingleLine() {
        tileContentView.setSingleLine()
    }
}

TextInputView.kt

/**
 * This is a widget, that when clicked, provides the user with a text input option.
 * The text can be obtained by calling getText(), which is a trimmed string.
 * Since the TextInputView is abstract and therefore not final, the init method must be called from
 * the init method of the child view, to inflate the root layout.
 */
abstract class TextInputView @JvmOverloads internal constructor(context: Context,
                                                       attrs: AttributeSet? = null,
                                                       @AttrRes defStyleAttr: Int = 0,
                                                       @StyleRes defStyleRes: Int = 0) :
        TileView(context, attrs, defStyleAttr, defStyleRes) {

    open var maxLength = 0
    open var keyListener: KeyListener? = null
    open var inputType = InputType.TYPE_CLASS_TEXT or
            InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or
            InputType.TYPE_TEXT_FLAG_MULTI_LINE

    @CallSuper
    override fun init() {
        super.init()
    }

    override fun onClick(v: View) {
        showEditTextDialog()
    }

    private fun showEditTextDialog() {
        val dialog = createEditTextDialog(createEditText())
        showDialogWithVisibleKeyboard(dialog)
    }

    private fun createEditText() = EditText(context)
            .apply {
                this.setText(super.text)
                this.hint = super.hint
                setSelection(text.length)
                setInputType(this)
                setKeyListener(this)
                setMaxLength(this)
                requestFocus()
            }

    private fun setInputType(editText: EditText) {
        editText.inputType = inputType
    }

    private fun setKeyListener(editText: EditText) {
        keyListener?.let { editText.keyListener = it }
    }

    private fun setMaxLength(editText: EditText) {
        if (maxLength != 0) {
            editText.filters = arrayOf(InputFilter.LengthFilter(maxLength))
        }
    }

    private fun createEditTextDialog(editText: EditText) = AlertDialog.Builder(context)
            .setTitle(label)
            .setPositiveButton(R.string.action_ok) { _, _ ->
                text = editText.text.toString().trim()
            }
            .setNegativeButton(R.string.action_cancel, null)
            .create()
            .apply {
                setDialogContent(editText, this)
            }

    private fun setDialogContent(content: View, dialog: AlertDialog) {
        val margin = resources.getDimensionPixelSize(R.dimen.standard_margin)
        dialog.setView(content, margin, margin, margin, 0)
    }

    private fun showDialogWithVisibleKeyboard(dialog: AlertDialog) {
        val window = dialog.window
        window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
        dialog.show()
    }
}

CommentView.kt

/**
 * This is a widget, that when clicked, provides the user with a comment input option.
 * The comment can be obtained by calling getText(), which is a trimmed string.
 * Label text and hint text can be set via XML.
 */
class CommentView @JvmOverloads constructor(context: Context,
                                            attrs: AttributeSet? = null,
                                            @AttrRes defStyleAttr: Int = 0,
                                            @StyleRes defStyleRes: Int = 0) :
        TextInputView(context, attrs, defStyleAttr, defStyleRes) {

    init {
        super.init()
        context.theme.obtainStyledAttributes(attrs, R.styleable.CommentView, defStyleAttr, defStyleRes)
                .apply {
                    try {
                        val type = getInt(R.styleable.CommentView_commentType, COMMENT)
                        initLayout(type)
                    } finally {
                        recycle()
                    }
                }
        setDrawableRes(R.drawable.ic_chat_bubble_outline_gray_24dp)
    }

    private fun initLayout(type: Int) {
        when (type) {
            COMMENT -> {
                setLabelRes(R.string.comment)
                setHintRes(R.string.add_comment)
            }
            NOTICE -> {
                setLabelRes(R.string.notice)
                setHintRes(R.string.add_notice)
            }
            PURPOSE -> {
                setLabelRes(R.string.purpose)
                setHintRes(R.string.add_purpose)
            }
        }
    }

    companion object {
        // These values must match those in the attrs declaration
        private const val COMMENT = 1
        private const val NOTICE = 2
        private const val PURPOSE = 3
    }
}

Вопросы:

  1. Я не считаю, что вышеуказанный метод @CallSuper init() является лучшим решением, но я не знаю, как реализовать его каким-либо другим способом. Как я могу реализовать это лучше, чтобы достичь того, что я хочу? Можно ли как-то использовать встроенный init {} -метод без утечки памяти?
  2. Я бы хотел, чтобы переменная TileView text содержала прямую ссылку на поле tileContentView.text, поэтому я Я могу установить null -значение и быть уверенным, что всегда получаю пустую строку вместо null (как стандарт TextView в настоящее время ведет себя).
...