В этой статье, приуроченной к онлайн-курсу Android Developer. Basic, мы напишем свою собственную небольшую игру «Крестики-нолики». Для работы потребуется установить Android Studio. Какая у вас ОС — не имеет значения, т.к. эта IDE работает на Windows, Ubuntu или Mac. Язык программирования будет Kotlin, т.к. сейчас он является официальным языком для программирования на Android. Во время написания статьи использовалась версия Android Studio 4.1.3

Создание проекта

Для начала необходимо создать новый проект. В нашем случае необходимо выбрать Fragment + ViewModel и нажать Next.

Крестики-нолики

Далее в поле Name вводите название приложения, в Package name можете внести свое имя или ник как доменное имя, но с зада на перед. Это нужно для уникальности вашего приложения в маркете и на телефоне. Все остальное можно оставить по умолчанию как на картинке. Далее нажимаем Finish и ждем, когда проект соберется и мы сможем работать.

Крестики-нолики

Нам нужно добавить зависимости которых нам не хватает в build.gradle модуля в раздел dependencies. А так же включить viewBinding для того, чтобы было удобна делать связь верстки с кодом (смотри изображение)

android {
…
    buildFeatures {
        viewBinding true
    }

}

dependencies {
…

    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0"
    implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
    implementation "androidx.gridlayout:gridlayout:1.0.0"

    implementation "androidx.fragment:fragment-ktx:1.3.0"
}
Крестики-нолики

Верстка экрана

Теперь можно преступить к верстанию экрана. Для начала мы добавим только несколько вьюшек. Для того, чтобы не нужно было у каждой вьюшке проставлять отступы от края экрана, воспользуемся специальным виджетом `Guideline`. Он позволяет выставить границы, за которые будут «цепляться» вьюшки. Если у вас экран открылся в режиме `Design`, то я рекомендую перейти в режим `Split`. Так удобнее мне объяснять и можно будет копировать однотипные элементы. Чтобы добавить Guideline можно внутри элемента `ConstraintLayout` написать `<guideline` и студия подскажет нужный нам элемент, который мы сможем использовать. Или справа нажать на кнопку `Guidelines` и выбрать горизонтальный или вертикальный guideline. Давайте добавим вертикальный. У нас на макете сразу появится справа полоса. Продублируем элемент Gidline, скопировав его полностью и вставив 3 раза. В параметр android:id напишем @+id/ плюс название в зависимости от расположения (start, end, top, bottom). В параметре android:orientation поставим vertical/horizontal в зависимости от направления линии. И изменить параметр app:layout_constraintGuide_begin на app:layout_constraintGuide_end для элементов с id=end/bottom.

Крестики-нолики

Теперь добавим отображение того, чей сейчас ход Х или О. Для этого добавим 3 ImageView. Но для начала, давайте создадим сами иконки Х и О, а так же ß для того, чтобы показывать чей ход.

Для этого справа в области project щелкните правой кнопкой мыши (ПКМ) выберите New → Vector Asset.

Крестики-нолики

В появившемся окне щелкнуть по изображению в пункте Clip Art, найти по поиску back стрелку назад и нажать OK. Я выбрал, чтобы концы были скругленными, для этого во втором выпадающем списке выбрать Round. Вы можете посмотреть, как выглядят остальные и выбрать понравившийся. Размер я задал 64 в пункте Size, а цвет в Color: F44336. Вы же в палитре можете выбрать тот цвет, который Вам больше всего понравился. Название в пункте Name я указал ic_arrow, чтоб проще было использовать. Для иконок, принято название начинать с ic_ и в snake case, т.е. с подчеркиванием. И нажимаем Next → Finish. Теперь самостоятельно сделайте крестик и нолик. Для них я выбрал иконку close и panorama fish eye соответственно и выбрал цвет 454FCE, а названия дал ic_cross и ic_circle.

Крестики-нолики

После всех этих действий, в разделе res → drawable появятся новые файлы с нашими иконками. Я рекомендую значение цвета из параметра android:tint перенести в параметр android:fillColor, а сам tint удалить. На некоторых китайских телефонах, отображение ведет себя  не корректно и качество иконки ухудшается.

Крестики-нолики

Теперь давайте вернемся к верстке и добавим 3 элемента ImageView. Для этого перейдем в режим Design (это как один из способов верстания экрана), найдем ImageView и перетащим его на макет. В появившемся экране выберем иконку ic_cross. В поле id напишем название cross. Таким же способом перетащим еще 2 элемента для ic_arrow и ic_circle и дадим им имя id direction и circle соответственно. Теперь выделим каждый элемент поочередно и потянув за верхний круг свяжем вьюшку с верхним guideline.

Крестики-нолики

Теперь сделаем из них цепочку, чтоб они были посередине и склеены друг с другом. Для этого выделяем эти 3 элемента вышкой, нажимаем ПКМ → Chains → Create Horizontal Chain.

Крестики-нолики

Затем снова ПКМ → Chains → Horizontal Chain Style → packed. Попробуйте другие стили и посмотрите, чем они отличаются друг от друга.

Крестики-нолики

Теперь они склеились вместе и находятся посередине, как я и хотел.

Добавим еще один элемент Barrier, который служит для того, чтобы следующий элемент расположить после других, не зависимо от того, какого размера будет каждый. Нажмем на кнопку Guidelines → Add Horizontal Barrier.

Крестики-нолики

И установим следующие значения:

<androidx.constraintlayout.widget.Barrier
    android:id="@+id/barrier"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:barrierDirection="bottom"
    app:constraint_referenced_ids="circle,cross,direction" />

У вас должно получиться что-то вроде этого.

Крестики-нолики

Теперь осталось добавить последний элемент верстки это GridLayout. Но его нужно выбрать из подключенной библиотеки и выбрать androidx.gridlayout.widget.GridLayout. Его уже нужно добавить вручную.

<androidx.gridlayout.widget.GridLayout
    android:id="@+id/field"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:columnCount="3"
    app:layout_constraintBottom_toBottomOf="@id/bottom"
    app:layout_constraintDimensionRatio="1:1"
    app:layout_constraintEnd_toEndOf="@id/end"
    app:layout_constraintStart_toStartOf="@id/start"
    app:layout_constraintTop_toTopOf="@id/barrier"
    app:rowCount="3">

</androidx.gridlayout.widget.GridLayout>

Аргументы  app:columnCount и app:rowCount отвечают за количество колонок и строк соответственно. Устанавливаем значение 3, т.к. поле у нас 3х3. Аргументы  app:layout_constraintDimensionRatio отвечает за соотношение сторон. Для того, что бы у нас был квадрат нужно его выставить в 1:1, вот из-за этого аргументаи пришлось подключать библиотеку, т.к. на версиях Android 5 этот аргумент не работает. И для того, чтобы соотношение работало, необходимо установить android:layout_width и android:layout_height в 0dp. Аргументы  app:layout_constraintXXX_toXXX отвечают за привязку нашей вьюшки к другим элементам. Ну и запишем id="@+id/field", а во внутрь добавим ImageView. Но так как их понадобиться 9 штук и чтобы у каждой вьюшке не прописывать одни и те же аргументы, можно создать стиль. Для этого щелкаем ПКМ по values → New → Values Resource File.

Крестики-нолики

 В появившемся окне, в поле File name указываем styles и нажимаем OK. В созданном файле сделаем следующую запись:

<resources>
    <style name="CellStyle">
        <item name="layout_rowWeight">1</item>
        <item name="layout_columnWeight">1</item>
        <item name="android:background">?android:attr/selectableItemBackground</item>
        <item name="android:layout_width">0dp</item>
        <item name="android:layout_height">0dp</item>
        <item name="android:adjustViewBounds">true</item>
    </style>

</resources>

Аргументы layout_columnWeight и layout_rowWeight служат для установки весов вьюшек и чтобы они равными размерами распределялись по ширине и высоте соответственно. Для того, чтобы эти веса заработали, необходимо android:layout_width и android:layout_height установить в 0dp. А в android:background прописываем атрибут, который будет браться из темы и отображать нажатие на вьюшку. Теперь осталось подключить этот стиль к нашей вьюшке. Возвращаемся к нашей верстке и у созданной ImageView меняем на следующее:

<ImageView
    style="@style/CellStyle"
    tools:src="@drawable/ic_cross" />

style="@style/CellStyle" – позволяет применить наш стиль для разных вьюшек, а tools:src="@drawable/ic_cross" позволяет отображать картинку на макете, но когда проект будет создан и установлен на телефоне, то картинки не будет. Продублируем этот элемент, чтобы в общем счете их получилось 9. В итоге должно получиться следующее:

Крестики-нолики

Соединяем верстку с кодом (Binding)

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

private lateinit var binding: MainFragmentBinding
private val viewModel: MainViewModel by viewModels()

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    binding = MainFragmentBinding.inflate(inflater, container, false)
    return binding.root
}

А оставшийся метод onActivityCreated полностью удалите. Все элементы, которые студия подчеркнет, нужно импортировать с помощью подсказок.

Пишем код

Теперь давайте перейдем в класс MainViewModel и уберем сгенерированные TODO. Нам необходимо хранить состояния ячеек (пустая, крестик, нолик) и для этого создадим массив этих состояний потому, что в Gridlayout все ячейки хранятся списком. А для расчета того кто победил, будем переводить индекс в массиве в координаты [строка:колонка], но об этом позже.

Создадим enum class для хранения состояния. В нем будет храниться картинка и возможность по ней кликать, ведь когда мы уже поставили символ или игра закончилась, то уже кликать по ячейкам нельзя. Напишем следующее в этом же файле в самом низу за пределами `}`.

enum class CellState(@DrawableRes val icon: Int, val isClickable: Boolean) {
    None(0, true),
    Cross(R.drawable.ic_cross_anim, false),
    Circle(R.drawable.ic_circle_anim, false)
}

Создадим поле с массивом этих состояний, я назову ее matrix для простоты. Так же добавим поле для хранения текущего состояния, т.е. чей сейчас ход и создадим метод initGame для инициализации начального состояния игры при первом запуске приложения (init – вызывается при создании класса MainViewModel) и при сбросе игры (onReloadClick).

class MainViewModel : ViewModel() {

    private lateinit var matrix: Array<CellState>
    private lateinit var currentCellState: CellState

init {
    initGame()
}

private fun initGame() {
    matrix = Array<CellState>(9) { CellState.None }
    currentCellState = CellState.Cross
}

fun onReloadClick() {
    initGame()
}

}

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

…
    private val mCurrentMove = MutableLiveData<CellState>()
    val currentMove: LiveData<CellState> = mCurrentMove
 …

private fun initGame() {
    …
    mCurrentMove.value = currentCellState
}

fun onCellClick(index: Int) {
    matrix[index] = currentCellState

    currentCellState = if (currentCellState == CellState.Cross) CellState.Circle else CellState.Cross
    mCurrentMove.value = currentCellState
}

Здесь mCurrentMove является изменяемым и он закрыт для обращения из вне класса, а currentMove является не изменяемым и он доступен снаружи класса.

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

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.field.forEachIndexed { index, v ->
            v.setOnClickListener { viewModel.onCellClick(index) }
        }

        viewModel.currentMove.observe(viewLifecycleOwner) {
            binding.direction.animate()
                .rotation(if (it == CellState.Cross) 0f else 180f)
//                .scaleY(if (it == Mark.Cross) 1f else -1f)
        }
    }

binding.field.forEachIndexed – к каждой вьюшке находящейся в GridLayout устанавливаем слушатель на клик.

binding.direction – это наша стрелочка на экране. Соберите проект и запустите его, посмотрите что происходит когда мы кликаем по ячейкам. Попробуйте убрать комментирование со строки со scaleY, а строку с rotation закомментировать или убрать и посмотреть что получилось.

Добавим отображение иконке в той ячейке, по которой мы кликнули. Чтобы не перерисовывать весь массив ячеек, а менять значение только той, по которой мы кликнули, будем передавать индекс ячейки и ее новое значение. Для этого добавим еще одно поле mCellStateByIndex, в которую будет передаваться индекс и новое состояние ячейки.

--- MainViewModel.kt ---
private val mCellStateByIndex: MutableLiveData<Pair<Int, CellState>> = SingleLiveEvent()
val cellStateByIndex: LiveData<Pair<Int, CellState>> = mCellStateByIndex

fun onCellClick(index: Int) {
    matrix[index] = currentCellState
    mCellStateByIndex.value = Pair(index, currentCellState)
    …
}
--- MainFragment.kt ---
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    …
    viewModel.cellStateByIndex.observe(viewLifecycleOwner) {
        val (index, state) = it
        with(binding.field.getChildAt(index) as ImageView) {
            isEnabled = state.isClickable
            setImageResource(state.icon) 
        }
    }
}
--- SingleLiveEvent.kt ---
class SingleLiveEvent<T> : MutableLiveData<T>() {

    private val pending = AtomicBoolean(false)

    override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
        super.observe(owner, Observer { t: T ->
            if (pending.compareAndSet(true, false)) {
                observer.onChanged(t)
            }
        })
    }

    override fun setValue(value: T) {
        pending.set(true)
        super.setValue(value)
    }
}

SingleLiveEvent – этот класс служит для того, чтобы при смене конфигурации (например, переворота экрана) не вызывалось снова событие на отрисовку вьюшки. Этот класс нужно создать отдельным файлом.

Добавим еще состояние нашей игры (запущена и остановлена), чтобы нельзя было кликать по ячейкам, когда игра завершилась и LiveData, в которой будет храниться состояние игры и список состояний ячеек

Создадим enum GameStatus и напишем его ниже CellState.

enum class GameStatus {
    Started,
    Finished
}

Добавим поле mStates и сделаем несколько изменений.

--- MainViewModel.kt ---
private val mStates = MutableLiveData<Pair<GameStatus, Array<CellState>>>()

val states: LiveData<Pair<GameStatus, Array<CellState>>> = mStates

private fun initGame() {
    … 
    mStates.value = Pair(GameStatus.Started, matrix)
}
--- MainFragment.kt ---
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        …
        viewModel.states.observe(viewLifecycleOwner) {
            val (status, matrix) = it
            matrix.forEachIndexed { index, state ->
                with(binding.field.getChildAt(index) as ImageView) {
                    setImageResource(state.icon)
                    isEnabled = state.isClickable && status == GameStatus.Started
                }
            }
        }
    }

Создаем свою вьюшку и рисуем на ней сетку

Создадим новый файл. Для этого в правой части проекта щелкните ПКМ по ru.otus.tictactoe → New → Kotlin Class/File и введите название FieldView.

Крестики-нолики

И напишите следующее в этот файл:

--- FieldView.kt ---
import androidx.gridlayout.widget.GridLayout

class FieldView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : GridLayout(context, attrs, defStyleAttr) {

    private var widthF: Float = 0f
    private var heightF: Float = 0f
    private var cellWidth: Float = 0f
        set(value) {
            field = value
            halfCellWidth = value / 2
        }

    private var cellHeight: Float = 0f
        set(value) {
            field = value
            halfCellHeight = value / 2
        }
    private var halfCellWidth: Float = 0f

    private var halfCellHeight: Float = 0f
    private val strokeSize = resources.getDimension(R.dimen.stroke_size)

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = ResourcesCompat.getColor(context.resources, R.color.penColor, null)
        strokeWidth = strokeSize
        strokeCap = Paint.Cap.ROUND
    }

    override fun onMeasure(widthSpec: Int, heightSpec: Int) {
        super.onMeasure(widthSpec, heightSpec)
        widthF = MeasureSpec.getSize(widthSpec).toFloat()
        heightF = MeasureSpec.getSize(heightSpec).toFloat()

        cellWidth = widthF / 3f
        cellHeight = heightF / 3f
    }

    override fun dispatchDraw(canvas: Canvas?) {
        super.dispatchDraw(canvas)
        canvas?.run {
            for (i in 1..2) {
                drawLine(cellWidth * i, strokeSize, cellWidth * i, heightF - strokeSize, paint)
                drawLine(strokeSize, cellHeight * i, widthF - strokeSize, cellHeight * i, paint)
            }
    }
}

Обязательно нужно наследоваться от androidx.gridlayout.widget.GridLayout.

Еще нужно добавить в ресурсы в которых будет указан цвет и размер линии. Для этого ПКМ по res → New → Android Resource File.

Крестики-нолики

В появившемся окне, в поле File name: написать values и нажать OK.

Крестики-нолики

Появится новый файл values.xml. откройте его и напишите следующее:

<resources>
    <color name="penColor">#454FCE</color>
    <dimen name="stroke_size">8dp</dimen>
</resources>

Теперь ошибки должны исчезнуть.

Осталось поменять нашу вьюшку в верстке.

<ru.otus.tictactoe.views.FieldView
    android:id="@+id/field"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:columnCount="3"
    app:layout_constraintBottom_toBottomOf="@id/bottom"
    app:layout_constraintDimensionRatio="1:1"
    app:layout_constraintEnd_toStartOf="@+id/end"
    app:layout_constraintStart_toStartOf="@+id/start"
    app:layout_constraintTop_toTopOf="@id/barrier"
    app:rowCount="3">

Вычисляем, кто выиграл

Для начала создадим sealed class с возможными вариантами выигрыша (по горизонтали, по вертикали, по диагонали и нет выигрыша).

--- FieldView.kt ---
private var mWinLineState: WinLineState = WinLineState.None
private var winLineRect: RectF? = null

override fun onMeasure(widthSpec: Int, heightSpec: Int) {
    …
    drawWinLine(mWinLineState)
}

override fun dispatchDraw(canvas: Canvas?) {
    super.dispatchDraw(canvas)
    canvas?.run {
        …
        winLineRect?.let {
            val line: RectF = when (mWinLineState) {
                is WinLineState.Horizontal ->
                    RectF(it.left, it.top, it.right, it.bottom)
                is WinLineState.Vertical ->
                    RectF(it.left, it.top, it.right, it.bottom)
                WinLineState.MainDiagonal ->
                    RectF(it.left, it.top, it.right, it.bottom)
                WinLineState.ReverseDiagonal ->
                    RectF(it.left, it.top, it.right, it.bottom)
                WinLineState.None -> it
            }
            drawLine(line.left, line.top, line.right, line.bottom, paint)
        }

    }
}

fun drawWinLine(winLineState: WinLineState) {
    mWinLineState = winLineState
    winLineRect = when (winLineState) {
        WinLineState.MainDiagonal ->
            RectF(strokeSize, strokeSize, widthF - strokeSize, heightF - strokeSize)
        WinLineState.ReverseDiagonal ->
            RectF(widthF - strokeSize, strokeSize, strokeSize, heightF - strokeSize)
        is WinLineState.Horizontal -> {
            val y = halfCellHeight + cellHeight * winLineState.row
            RectF(strokeSize, y, widthF - strokeSize, y)
        }
        is WinLineState.Vertical -> {
            val x = halfCellWidth + cellWidth * winLineState.column
            RectF(x, strokeSize, x, heightF - strokeSize)
        }
        WinLineState.None -> null
    }
    if (winLineRect == null) return
    invalidate()
}

sealed class WinLineState {
    data class Horizontal(val row: Int) : WinLineState()
    data class Vertical(val column: Int) : WinLineState()
    object MainDiagonal : WinLineState()
    object ReverseDiagonal : WinLineState()
    object None : WinLineState()
}

Создадим методы расчета выигрышного положения.

--- MainViewModel.kt ---

private val mWinState : MutableLiveData<WinLineState> = MutableLiveData()

val winState: LiveData<WinLineState> = mWinState

private fun initGame() {
    …
    mWinState.value = WinLineState.None
}

fun onCellClick(index: Int) {
    matrix[index] = currentCellState
    mCellStateByIndex.value = Pair(index, currentCellState)

    val row = index / 3
    val column = index % 3
    val state = checkWin(row, column)
    if (state != WinLineState.None) {
        mStates.value = Pair(GameStatus.Finished, matrix)
        mWinState.value = state
        return
    }

    currentCellState = if (currentCellState == CellState.Cross) CellState.Circle else CellState.Cross
    mCurrentMove.value = currentCellState
}

private fun checkWin(row: Int, column: Int): WinLineState {
    //check row
    if (checkLine { matrix[getIndex(row, it)] == currentCellState }) return WinLineState.Horizontal(row)
    // check column
    if (checkLine { matrix[getIndex(it, column)] == currentCellState }) return WinLineState.Vertical(column)
    if (row == column) {
        // check main diagonal
        if (checkLine { matrix[getIndex(it, it)] == currentCellState }) return WinLineState.MainDiagonal
    }
    if (row + column == 2) {
        // check reverse diagonal
        if (checkLine { matrix[getIndex(it, 2 - it)] == currentCellState }) return WinLineState.ReverseDiagonal
    }
    return WinLineState.None
}

private fun checkLine(function: (Int) -> Boolean): Boolean {
    for (i in 0..2) {
        if (!function(i)) return false
    }
    return true
}

private fun getIndex(row: Int, column: Int) = row * 3 + column

--- MainFragment.kt ---

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    …
    viewModel.winState.observe(viewLifecycleOwner) { binding.field.drawWinLine(it) }
}

Сбрасывание состояния игры

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

Добавьте иконку cached и назовите ее ic_reload. Добавьте ресурс, как это делали с цветом и размером, только в открывшемся окне, поменяйте Resource type на Menu и в File name напишите  menu_reload. А в ресурсах strings добавьте название кнопки

--- res\menu\menu_reload.xml ---
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/action_reload"
        android:icon="@drawable/ic_reload"
        android:title="@string/reload"
        app:showAsAction="always"/>
</menu>
--- res\values\strings.xml  ---
<string name="reload">reload</string>

Теперь осталось только добавить ее и реализовать обработку этой кнопки. Для этого переходим в MainFragment и добавляем следующее.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setHasOptionsMenu(true)
}

override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    super.onCreateOptionsMenu(menu, inflater)
    inflater.inflate(R.menu.menu_reload, menu)
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
    if (item.itemId == R.id.action_reload) {
        viewModel.onReloadClick()
        return true
    }
    return super.onOptionsItemSelected(item)
}

Подводим итоги

Крестики-нолики

На этом все. Можно запустить проект и посмотреть что получилось. Исходник можно взять из github (https://github.com/shustreek/TicTacToe). Там я добавил анимацию рисования крестика и нолика, а также линии при выигрыше.