- PVSM.RU - https://www.pvsm.ru -

Пишем MVP приложение на Kotlin под Android

Пишем MVP приложение на Kotlin под Android - 1

Разработка приложений на Kotlin под Android набирает популярность среди разработчиков, однако статей в русскоязычном сегменте Интернета довольно мало. Я решил немного подправить ситуацию, и написать туториал по разработке приложения на Kotlin. Мы напишем полноценное приложение с использованием всех трендовых библиотек (кроме RxJava) в мире Android-разработки. В конце у нас должно получиться расширяемое и легко тестируемое приложение (сами тесты мы писать не будем).

Наверное, некоторые из вас знают, что помимо языка программирования Kotlin JetBrains также разрабатывает библиотеку Anko [1], для создания UI приложения, в качестве замены обычным XML-файлам. Мы не будем использовать его в нашем проекте, дабы не ставить в затруднительное положение людей не знакомых с Anko.

Внимание: якори в содержании почему-то не работают. Если знаете как решить проблему, напишите в ЛС, пожалуйста.

Содержание:

Настройка Android Studio

Для написания приложений на языке Kotlin, Android Studio нужен специальный плагин. Инструкцию по установке плагина можно найти здесь [15]. Также не забудьте отключить функцию «Instant Run» в настройках Android Studio, т. к. на данный момент она не поддерживается плагином Kotlin.

Для корректной работы генерации кода нужно использовать версию плагина не ниже 1.0.1. Я использовал версию Kotlin 1.0.2 EAP [16]. Вот так выглядит файл build.gradle приложения в моем проекте:

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-android'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.2"

    defaultConfig {
        applicationId "imangazaliev.notelin"
        minSdkVersion 15
        targetSdkVersion 23
        versionCode 1
        versionName "1.0"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    sourceSets {
        main.java.srcDirs += 'src/main/kotlin'
        androidTest.java.srcDirs += 'src/androidTest/kotlin'
    }
}

dependencies {
    ...
}

kapt {
    generateStubs = true
}

buildscript {
    ext.kotlin_version = '1.0.2-eap-15'
    repositories {
        mavenCentral()
        maven { url 'https://dl.bintray.com/kotlin/kotlin-eap' }
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}
repositories {
    mavenCentral()
}

Что будем писать?

Итак, для начала нам нужно определиться что же мы будем писать? Недолго думая я остановился на приложении-заметках. Название тоже придумалось легко — Notelin. Приложение очень простое и состоит из двух экранов:

— Главный экран — содержит в себе список с заметками
— Экран заметки — здесь можно смотреть/редактировать содержание выбранной заметки

Требования к приложению небольшие:

— Добавление/просмотр/удаление заметки
— Просмотр информации о заметке
— Сортировка заметок по заголовку и по дате
— Поиск по заголовкам заметок

Используемые библиотеки

Для работы с базой данных я буду использовать библиотеку Android Active. Урок по работе с ней можно найти по этой ссылке [17]. Для реализации Depency Injection была использована библиотека Dagger 2. На Хабре есть много статей по работе с ней. Основой всего приложения будет библиотека Moxy [18]. С ее помощью мы реализуем паттерн MVP в нашем проекте. Она полностью решает проблемы жизненного цикла, благодаря чему вы можете не переживать о пересоздании компонентов вашего приложения. Также мы воспользуемся набором расширений для языка Kotlin в Android — KAndroid [19]. Про остальные библиотеки я буду рассказывать по ходу дела.

Ниже приведен список зависимостей проекта:

allprojects {
    repositories {
        jcenter()
        mavenCentral()
        maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
        maven { url "https://mvn.arello-mobile.com/" }
        maven { url "https://jitpack.io" }
        maven { url 'https://dl.bintray.com/kotlin/kotlin-eap' }
    }
}

А вот так выглядит список зависимостей приложения:

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
    compile "com.android.support:appcompat-v7:23.1.1"
    compile 'com.android.support:recyclerview-v7:23.1.1'
    compile 'com.android.support:cardview-v7:23.1.1'
    //дополнительные возможности для Android Kotlin
    compile 'com.pawegio.kandroid:kandroid:0.5.0@aar'
    //ActiveAndroid DB
    compile 'com.michaelpardo:activeandroid:3.1.0-SNAPSHOT'
    //FAB
    compile 'com.melnykov:floatingactionbutton:1.3.0'
    //MaterialDialog
    compile 'com.github.afollestad.material-dialogs:core:0.8.5.6@aar'
    //MVP
    compile 'com.arello-mobile:moxy:0.4.2'
    compile 'com.arello-mobile:moxy-android:0.4.2'
    kapt 'com.arello-mobile:moxy-compiler:0.4.2'
    //RX
    compile 'io.reactivex:rxjava:1.1.0'
    compile 'io.reactivex:rxandroid:1.1.0'
    //Depency Injection
    kapt 'com.google.dagger:dagger-compiler:2.0.2'
    compile 'com.google.dagger:dagger:2.0.2'
    provided 'org.glassfish:javax.annotation:10.0-b28'
    //EventBus
    compile 'org.greenrobot:eventbus:3.0.0'
}

Обратите внимание, что вместо apt я использую kapt [20]. Это плагин для Gradle, позволяющий аннотировать Kotlin-элементы.

Структура приложения

Вот так выглядит структура нашего проекта в конечном варианте:

Пишем MVP приложение на Kotlin под Android - 2

Создаем Model

У заметок будет четыре поля:

  • Дата создания
  • Дата изменения
  • Заголовок
  • Текст

Реализуем все это в коде:

class Note : Model {

    @Column(name = "title")
    public var title: String? = null
    @Column(name = "text")
    public var text: String? = null
    @Column(name = "create_date")
    public var createDate: Date? = null
    @Column(name = "change_date")
    public var changeDate: Date? = null

    constructor(title: String, createDate: Date, changeDate: Date) {
        this.title = title
        this.createDate = createDate
        this.changeDate = changeDate
    }

    constructor()

    fun getInfo(): String = "Название:n$titlen" +
                                       "Время создания:n${DateUtils.formatDate(createDate)}n" +
                                       "Время изменения:n${DateUtils.formatDate(changeDate)}";
}

По этой модели библиотекой ActiveAndroid будет создана БД, в которой будут храниться наши заметки. Если вы заметили, у нас есть два конструктора: пустой и с параметрами. Первый конструктор будем использовать мы, а второй — ActiveAndroid. Наша модель наследуется от класса Model, благодаря чему мы можем сохранять и удалять наши заметки просто вызывая методы save() и delete(), например:

var note = Note("Новая заметка", Date())
note.save()
...
note.delete()

Но прежде чем использовать нашу модель, нам нужно прописать кое-какие мета-данные в Manifest-файле:

<meta-data android:name="AA_DB_NAME" android:value="Notelin.db" />
<meta-data android:name="AA_DB_VERSION" android:value="1" />

Думаю, все понятно без комментариев. Осталось унаследовать класс Application от com.activeandroid.app.Application:

class NotelinApplication : Application() {
...
}

Чтобы приложение было менее зависимо от БД я создал обертку NoteWrapper [21] над нашей моделью, в которой будут происходить все операции по созданию, сохранению, обновлению и удалению заметок:

class NoteWrapper {

    /**
     * Создает новую заметку
     */
    fun createNote(): Note {
        var note = Note("Новая заметка", Date())
        note.save()
        return note
    }

    /**
     * Сохраняет заметку в БД
     */
    fun saveNote(note: Note) : Long {
        return note.save()
    }

    /**
     * Загружает все существующие заметки и передает во View
     */
    fun loadAllNotes() : List<Note> {
        return Select().from(Note::class.java).execute<Note>()
    }

    /**
     * Ищет заметку по id и возвращает ее
     */
    fun getNoteById(noteId:Long) : Note {
        return Select().from(Note::class.java).where("id = ?", noteId).executeSingle<Note>()
    }

    /**
     * Удаляет все существующие заметки
     */
    fun deleteAllNotes()  {
        Delete().from(Note::class.java).execute<Note>();
    }

    /**
     * Удаляет заметку по id
     */
    fun deleteNote(note:Note)  {
        note.delete()
    }

}

Наверное, вы заметили, что для создания объектов мы не использовали ключевое слово new — это отличие Kotlin от Java.

Экран с заметками

Также является главным экраном приложения. На нем пользователь может добавить/удалить заметку, просмотреть информацию о заметке, отсортировать их по дате или названию, а также произвести поиск по заголовкам.

Создаем MainView и MainPresenter

Пишем MVP приложение на Kotlin под Android - 3

Теперь нам нужно перевести все это в код. Для начала создадим интерфейс нашей View:

@StateStrategyType(value = AddToEndSingleStrategy::class)
interface MainView : MvpView {

    fun onNotesLoaded(notes: ArrayList<Note>)

    fun updateView()

    fun onSearchResult(notes: ArrayList<Note>)

    fun onAllNotesDeleted()

    fun onNoteDeleted()

    fun showNoteInfoDialog(noteInfo: String)

    fun hideNoteInfoDialog()

    fun showNoteDeleteDialog(notePosition: Int)

    fun hideNoteDeleteDialog()

    fun showNoteContextDialog(notePosition: Int)

    fun hideNoteContextDialog()

}

Далее мы реализуем созданный интерфейс в нашей активити:

class MainActivity : MvpAppCompatActivity(), MainView {

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

class MainActivity : MainView, MvpAppCompatActivity() {

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

Пока оставим методы пустыми. Как видите, активити наследуется от MvpAppCompatActivity [22]. Это нужно для того, чтобы активити могла восстанавливать состояние при повороте экрана.

Создадим класс презентер:

@InjectViewState
class MainPresenter : MvpPresenter<MainView> {

}

Презентер также наследуется от MvpPresenter, которому мы указываем с какой View мы будем работать.Осталось инжектировать нашу модель в презентер. Для этого мы создаем модуль — поставщика NoteWrapper:

@Module
class NoteWrapperModule {

    @Provides
    @Singleton
    fun provideNoteWrapper() : NoteWrapper= NoteWrapper()

}

Создадим Component для инжектирования презентера:

@Singleton
@Component(modules = arrayOf(NoteWrapperModule::class))
interface AppComponent {

    fun inject(mainPresenter : MainPresenter)

}

Теперь нам нужно создать статический экземпляр класса AppComponent в классе Application:

class NotelinApplication : Application() {

    companion object {
        lateinit var graph: AppComponent
    }

    override fun onCreate() {
        super.onCreate()

        graph = DaggerAppComponent.builder().noteWrapperModule(NoteWrapperModule()).build()
    }

}

Теперь мы можем инжектировать нашу модель в презентере:

@InjectViewState
class MainPresenter : MvpPresenter<MainView> {

    @Inject
    lateinit var mNoteWrapper: NoteWrapper

    constructor() : super() {
        NotelinApplication.graph.inject(this)
    }

}

Для взаимодействия MainView и MainPresenter нам нужно создать переменную в MainActivity:

@InjectPresenter
lateinit var mPresenter: MainPresenter

Плагин Moxy сам привяжет View к фрагменту и произведет другие необходимые действия.

Создадим разметку экрана со списком и плавающей кнопкой. Файл activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.activities.MainActivity">
    <TextView
        android:id="@+id/tvNotesIsEmpty"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="@string/notes_is_empty"
        android:gravity="center"
        />
    <android.support.v7.widget.RecyclerView
        android:id="@+id/rvNotesList"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scrollbars="vertical"
        app:layoutManager="android.support.v7.widget.LinearLayoutManager"
        />
    <com.melnykov.fab.FloatingActionButton
        android:id="@+id/fabButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|right"
        android:layout_margin="16dp"
        android:src="@mipmap/ic_add"
        app:fab_colorNormal="@color/colorPrimary"
        app:fab_colorPressed="@color/colorPrimaryDark" />
</FrameLayout>

Для реализации летающей кнопки я использовал библиотеку FloatingActionButton [23]. Google уже добавили FAB в support-библиотеку, поэтому вы можете воспользоваться их решением.

Укажем нашей Activity, какой макет она должна показывать:

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

    }

Далее нам нужно связать FAB и список, чтобы при прокручивании списка вверх кнопка исчезала:

fabButon.attachToRecyclerView(rvNotesList)

Нам не нужно писать порядком надоевший findViewById, нужно лишь прописать одну строчку в блоке с import'ами:

import kotlinx.android.synthetic.main.activity_main.*

Как видите, последний пакет совпадает с названием нашего xml-файла. IDE автоматически инициализирует свойства (property) наших View и их имена совпадают с ID, которые мы указали в разметке.

Давайте реализуем загрузку заметок из БД. Заметки нужно загружать только один раз и использовать их в последующем. В этом нам поможет метод onFirstViewAttach класса MvpPresenter, который вызывается единожды при первой привязке View к презентеру. Далее, сколько бы мы не крутили и вертели нашу Activity, данные будут закешированы в презентере.

override fun onFirstViewAttach() {
    super.onFirstViewAttach()

    loadAllNotes()
    }

/**
* Загружает все существующие заметки и передает во View
*/
fun loadAllNotes() {
    mNotesList = mNoteWrapper.loadAllNotes() as ArrayList<Note>
    viewState.onNotesLoaded(mNotesList)
}

Создадим адаптер для нашего списка:

Код адаптера

class NotesAdapter : RecyclerView.Adapter<NotesAdapter.ViewHolder> {

    private var mNotesList: List<Note> = ArrayList()

    constructor(notesList: List<Note>) {
        mNotesList = notesList
    }

    /**
     * Создание новых View и ViewHolder элемента списка, которые впоследствии могут переиспользоваться.
     */
    override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): ViewHolder {
        var v = LayoutInflater.from(viewGroup.context).inflate(R.layout.note_item_layout, viewGroup, false);
        return ViewHolder(v);
    }

    /**
     * Заполнение виджетов View данными из элемента списка с номером i
     */
    override
    fun onBindViewHolder(viewHolder: ViewHolder, i: Int) {
        var note = mNotesList[i];
        viewHolder.mNoteTitle.text = note.title;

        viewHolder.mNoteDate.text = DateUtils.formatDate(note.changeDate);
    }

    override fun getItemCount(): Int {
        return mNotesList.size
    }

    /**
     * Реализация класса ViewHolder, хранящего ссылки на виджеты.
     */

    class ViewHolder : RecyclerView.ViewHolder {

        var mNoteTitle: TextView
        var mNoteDate: TextView

        constructor(itemView: View) : super(itemView) {
            mNoteTitle = itemView.findViewById(R.id.tvItemNoteTitle) as TextView
            mNoteDate = itemView.findViewById(R.id.tvItemNoteDate) as TextView
        }

    }

}

В адаптере мы используем класс DateUtils. Он служит для форматирования даты в строку:

class DateUtils {

    companion object {

        fun formatDate(date: Date?): String {
            var dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm");
            return dateFormat.format(date)
        }

    }

}

Т. к. в Kotlin нет модификатора static, я поместил метод formatDate в блок companion object для того, чтобы мы могли обращаться к нему по имени класса, как к обычному статическому методу. В методе onNotesLoaded нашей Activity мы показываем наши заметки:

override fun onNotesLoaded(notes: ArrayList<Note>) {
    rvNotesList.adapter = NotesAdapter(notes)
    updateView()
}

override fun updateView() {
    rvNotesList.adapter.notifyDataSetChanged()
    if (rvNotesList.adapter.itemCount == 0) {
        rvNotesList.visibility = View.GONE
        tvNotesIsEmpty.visibility = View.VISIBLE
    } else {
        rvNotesList.visibility = View.VISIBLE
        tvNotesIsEmpty.visibility = View.GONE
    }
}

Если заметок нет, то мы показываем сообщение «Нет заметок» в TextView.

Насколько я знаю, для обработки клика по элементам RecycleView не существует «официального» OnItemClickListener. Поэтому мы воспользуемся своим решением:

ItemClickSupport.java

public class ItemClickSupport {

    private final RecyclerView mRecyclerView;
    private OnItemClickListener mOnItemClickListener;
    private OnItemLongClickListener mOnItemLongClickListener;

    private View.OnClickListener mOnClickListener = new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (mOnItemClickListener != null) {
                RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
                mOnItemClickListener.onItemClicked(mRecyclerView, holder.getAdapterPosition(), v);
            }
        }
    };

    private View.OnLongClickListener mOnLongClickListener = new View.OnLongClickListener() {
        @Override
        public boolean onLongClick(View v) {
            if (mOnItemLongClickListener != null) {
                RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
                return mOnItemLongClickListener.onItemLongClicked(mRecyclerView, holder.getAdapterPosition(), v);
            }
            return false;
        }
    };

    private RecyclerView.OnChildAttachStateChangeListener mAttachListener = new RecyclerView.OnChildAttachStateChangeListener() {
        @Override
        public void onChildViewAttachedToWindow(View view) {
            if (mOnItemClickListener != null) {
                view.setOnClickListener(mOnClickListener);
            }
            if (mOnItemLongClickListener != null) {
                view.setOnLongClickListener(mOnLongClickListener);
            }
        }

        @Override
        public void onChildViewDetachedFromWindow(View view) {

        }
    };

    private ItemClickSupport(RecyclerView recyclerView) {
        mRecyclerView = recyclerView;
        mRecyclerView.setTag(R.id.item_click_support, this);
        mRecyclerView.addOnChildAttachStateChangeListener(mAttachListener);
    }

    public static ItemClickSupport addTo(RecyclerView view) {
        ItemClickSupport support = (ItemClickSupport) view.getTag(R.id.item_click_support);
        if (support == null) {
            support = new ItemClickSupport(view);
        }
        return support;
    }

    public static ItemClickSupport removeFrom(RecyclerView view) {
        ItemClickSupport support = (ItemClickSupport) view.getTag(R.id.item_click_support);
        if (support != null) {
            support.detach(view);
        }
        return support;
    }

    public ItemClickSupport setOnItemClickListener(OnItemClickListener listener) {
        mOnItemClickListener = listener;
        return this;
    }

    public ItemClickSupport setOnItemLongClickListener(OnItemLongClickListener listener) {
        mOnItemLongClickListener = listener;
        return this;
    }

    private void detach(RecyclerView view) {
        view.removeOnChildAttachStateChangeListener(mAttachListener);
        view.setTag(R.id.item_click_support, null);
    }

    public interface OnItemClickListener {
        void onItemClicked(RecyclerView recyclerView, int position, View v);
    }

    public interface OnItemLongClickListener {
        boolean onItemLongClicked(RecyclerView recyclerView, int position, View v);
    }
}

Так как данный код написан на Java, то его можно использовать и в обычных Android-проектах. При желании можно переписать класс на Kotlin и «встроить» в RecycleView. О расширении класса в Kotlin можно почитать здесь [24].

В методе onCreate нашей Activity пишем:

        with(ItemClickSupport.addTo(rvNotesList)) {
            setOnItemClickListener { recyclerView, position, v -> mPresenter.openNote(this@MainActivity, position) }
            setOnItemLongClickListener { recyclerView, position, v -> mPresenter.showNoteContextDialog(position); true }
        }

Функция with позволяет не писать каждый раз имя переменной, а только лишь вызывать методы у объекта, который мы передали в нее. Обратите внимание, что для получения Activity я использовал не просто this, а this@MainActivity [25]. Это связано с тем, что при использовании this в блоке with, возвращается объект, который мы передали в функцию with. При обычном клике по пункту мы переходим на Activity, где мы можем просмотреть текст нашей заметки. При долгом нажатии появляется контекстное меню. Если вы заметили, перед закрывающей скобкой я не написал слово return. Это не ошибка, а особенность языка Kotlin.

Вот что происходит при нажатии на пункт меню в презентере:

/**
* Открывает активити с заметкой по позиции
*/
fun openNote(activity: Activity, position: Int) {
    val intent = Intent(activity, NoteActivity::class.java)
    intent.putExtra("note_id", mNotesList[position].id)
    activity.startActivity(intent)
}

Мы еще не создали класс NoteActivity, поэтому компилятор будет выдавать ошибку. Для решения этой проблемы можно создать класс NoteActivity или вовсе закомментировать код внутри метода openNote. Запись NoteActivity::class.java аналогична NoteActivity.class в Java. Также заметьте, что мы обращаемся к ArrayList не через метод get(position), а через квадратные скобки, как к обычному массиву.

При использовании MVP-библиотеки Moxy в своем приложении, нам нужно привыкать, что все действия с View такие как показ и закрытие диалога и другие, должны проходить через презентер. Изначально это не очень привычным и неудобным, но пользы от этого гораздо больше, т. к. мы можем быть уверены, что при пересоздании Activity наше диалоговое окно никуда не пропадет.

/**
* Показывает контекстное меню заметки
*/
fun showNoteContextDialog(position: Int) {
    viewState.showNoteContextDialog(position)
}

/**
 * Прячет контекстное меню заметки
*/
fun hideNoteContextDialog() {
    viewState.hideNoteContextDialog()
}

Я не буду показывать код контекстного меню, удаления и показа информации о заметке т. к. статья получается очень большой. Но, думаю, общий смысл вы уловили. Также следует отметить, что метод hideNoteContextDialog у презентера должен вызываться даже при закрвтие диалога через кнопку назад или при нажатии на область за границами диалога.

При нажатии на FAB должна создаваться новая заметка:

fabButton.setOnClickListener {
            mPresenter.openNewNote(this)
}

Для создания новой заметки мы вызываем у презентера функция openNewNote:

fun openNewNote(activity: Activity) {
        val newNote = mNoteWrapper.createNote()
        mNotesList.add(newNote)
        sortNotesBy(getCurrentSortMethod())
        openNote(activity, mNotesList.indexOf(newNote))
}

Метод openNewNote использует созданный нами ранее openNote, в который мы передаем Context и позицию заметки в списке.

Реализуем поиск по заметкам

Пишем MVP приложение на Kotlin под Android - 4

Давайте добавим поиск по заметкам. Создайте в папке res/menu файл main.xml:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <item
        android:id="@+id/action_search"
        android:icon="@android:drawable/ic_menu_search"
        android:title="@string/search"
        app:actionViewClass="android.support.v7.widget.SearchView"
        app:showAsAction="always" />

</menu>

В MainActivity пишем:

override fun onCreateOptionsMenu(menu: Menu): Boolean {
    menuInflater.inflate(R.menu.main, menu)

    initSearchView(menu)
    return true
}

private fun initSearchView(menu: Menu) {
    var searchViewMenuItem = menu.findItem(R.id.action_search);
    var searchView = searchViewMenuItem.actionView as SearchView;
    searchView.onQueryChange { query -> mPresenter.search(query) }
    searchView.setOnCloseListener { mPresenter.search(""); false }
}

При изменении текста в поле поиска мы передаем строку из поля в презентер, после чего показываем результаты в списке. На самом деле, у SearchView нет метода onQueryChange, его добавила библиотека KAndroid.

Реализуем поиск в презентере:

/**
* Ищет заметку по имени
*/
fun search(query: String) {
    if (query.equals("")) {
        viewState.onSearchResult(mNotesList)
    } else {
        val searchResults = mNotesList.filter { note -> note.title!!.toLowerCase().startsWith(query.toLowerCase()) }
        viewState.onSearchResult(searchResults as ArrayList<Note>)
    }
}

Обратите внимание, как красиво, в одну строчку мы реализовали поиск по ArrayList с помощью метода filter и лямбд. В Java тот же функционал занял бы 6-7строк. Осталось отобразить результаты поиска:

override fun onSearchResult(notes: ArrayList<Note>) {
    rvNotesList.adapter = NotesAdapter(notes)
}

Реализуем сортировку заметок

И последний этап в создании главного экрана, это сортировка заметок. Добавим в res/menu/main.xml следующие строки:

<item android:title="@string/sort_by">
    <menu>
        <item
            android:id="@+id/menuSortByName"
            android:title="@string/sort_by_title" />
        <item
            android:id="@+id/menuSortByDate"
            android:title="@string/sort_by_date" />
    </menu>
</item>

Теперь нам нужно обработать нажатие на пункты меню:

override fun onOptionsItemSelected(item: MenuItem): Boolean {
        when (item.itemId) {
            R.id.menuSortByName -> mPresenter.sortNotesBy(MainPresenter.SortNotesBy.NAME)
            R.id.menuSortByDate -> mPresenter.sortNotesBy(MainPresenter.SortNotesBy.DATE)
        }
    return super.onOptionsItemSelected(item)
}

Оператор when является более функциональным аналогом switch-case в Java. Код сортировки в MainPresenter:

/**
* Сортирует заметки
*/
fun sortNotesBy(sortMethod: SortNotesBy) {
    mNotesList.sortWith(getSortComparator(sortMethod))
    viewState.updateView()
}

fun getSortComparator(sortMethod: SortNotesBy): Comparator<Note> {
    when (sortMethod) {
        SortNotesBy.NAME -> return SortName()
        SortNotesBy.DATE -> return SortDate()
    }
}

А вот код самих Comparator'ов:

/**
 * Cортировка заметок по дате
 */
class SortDate : Comparator<Note> {
    override fun compare(note1: Note, note2: Note): Int {
        return  note1.changeDate!!.compareTo(note2.changeDate!!)
    }

}

/**
 * Cортировка заметок по имени
 */
class SortName : Comparator<Note> {
    override fun compare(note1: Note, note2: Note): Int {
        return  note1.title!!.compareTo(note2.title!!)
    }

}

Экран с содержанием заметки

Теперь нам нужно создать экран с содержанием заметки. Здесь пользователь может просмотреть/отредактировать заголовок и текст заметки, сохранить или удалить ее, а также просмотреть информацию о заметке.

Создаем NoteView и NotePresenter

Пишем MVP приложение на Kotlin под Android - 5

Экран содержит всего лишь три View:

-Заголовок
-Дата последнего изменения
-Текст заметки

А вот и сама разметка:

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


    <EditText
        android:id="@+id/etTitle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:singleLine="true"
        android:background="#EEEEEE"
        android:textColor="#212121"
        android:paddingLeft="10dp"
        android:paddingTop="10dp"
        android:paddingBottom="5dp"
        android:hint="Заголовок"
        />

    <TextView
        android:id="@+id/tvNoteDate"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:singleLine="true"
        android:background="#EEEEEE"
        android:textColor="#212121"
        android:paddingLeft="10dp"
        android:paddingBottom="10dp"
        />

    <EditText
        android:id="@+id/etText"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:textColor="#000000"
        android:gravity="start"
        android:hint="Текст"
        android:background="@null"
        android:paddingLeft="10dp"
        />

</LinearLayout>

В начале статьи я мельком упомянул об Anko. Библиотека позволяет существенно сократить код, не теряя при этом в удобочитаемости. Вот так, например, выглядела бы наша разметка при использовании Anko:

class MyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        MyActivityUI().setContentView(this)
    }
}

class MyActivityUI : AnkoComponent {

    companion object {
        val ET_TITLE_ID = View.generateViewId()
        val TV_NOTE_DATE_ID = View.generateViewId()
        val ET_TEXT_ID = View.generateViewId()
    }
    
    override fun createView(ui: AnkoContext<MainActivit>) = with(ui) {
        verticalLayout { 
            editText { 
                id = ET_TITLE_ID
                singleLine = true
                backgroundColor = 0xEE.gray.opaque
                textColor = 0x21.gray.opaque
                leftPadding = dip(10)
                topPadding = dip(10)
                bottomPadding = dip(5)
                hint = "Заголовок"
            }.lparams(matchParent, wrapContent)
            
            textView {
                id = TV_NOTE_DATE_ID
                singleLine = true
                backgroundColor = 0xEE.gray.opaque
                textColor = 0x21.gray.opaque
                leftPadding = dip(10)
                bottomPadding = dip(10)
            }.lparams(matchParent, wrapContent)
            
            editText { 
                id = ET_TEXT_ID
                textColor = Color.BLACK
                gravity = Gravity.START
                hint = "Текст"
                background = null
                leftPadding = dip(10)
            }
        }
    }
}

Но не будем отвлекаться и приступим к написанию кода. Первым делом нам нужно создать View:

interface NoteView : MvpView {

    fun showNote(note: Note)

    fun onNoteSaved()

    fun onNoteDeleted()

    fun showNoteInfoDialog(noteInfo: String)

    fun hideNoteInfoDialog()

    fun showNoteDeleteDialog()

    fun hideNoteDeleteDialog()

}

Имплементируем NoteView в NoteActivity:

class NoteActivity : MvpAppCompatActivity(), NoteView {

    @InjectPresenter
    lateinit var mPresenter: NotePresenter

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

        val noteId = intent.extras.getLong("note_id", -1)
        mPresenter.showNote(noteId)
    }

}

В onCreate мы извлекаем id заметки, чтобы презентер достал заметку из БД и передал данные во View. Создадим презентер:

@InjectViewState
class NotePresenter : MvpPresenter<NoteView> {

    @Inject
    lateinit var mNoteWrapper: NoteWrapper
    lateinit var mNote: Note

    constructor() : super() {
        NotelinApplication.graph.inject(this)
    }

    fun showNote(noteId: Long) {
        mNote = mNoteWrapper.getNoteById(noteId)
        viewState.showNote(mNote)
    }

}

Не забудьте добавить в класс AppComponent строку:

fun inject(notePresenter: NotePresenter)

Покажем нашу заметку:

override fun showNote(note: Note) {
    tvNoteDate.text = DateUtils.formatDate(note.changeDate)
    etTitle.setText(note.title)
    etText.setText(note.text)
}

Реализуем сохранение заметки

Для сохранения заметки нам нужно выбрать соответствующий пункт в меню. Создайте файл res/menu/note.xml:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    >
    <item
        android:id="@+id/menuSaveNote"
        android:title="@string/save"
        app:showAsAction="never"
        />
</menu>
override fun onCreateOptionsMenu(menu: Menu): Boolean {
    menuInflater.inflate(R.menu.note, menu)
    return true
}

Покажем меню в Activity:

override fun onOptionsItemSelected(item: MenuItem): Boolean {
    when (item.itemId) {
        R.id.menuSaveNote -> mPresenter.saveNote(etTitle.text.toString(), etText.text.toString())
    }
    return super.onOptionsItemSelected(item)
}

Опять же, я не стал приводить код удаления и вывода информации о заметке. При просмотре исходного кода, вы можете заметить, что помимо идентификатора заметки я передал в NoteActivity позицию заметки в списке. Это нужно для того, чтобы при удалении заметке на экране просмотра заметки, она также удалялась из списка. Для реализации этого функционала я использовал EventBus. И опять, я не стал приводить код.

На этом все: заметки добавляются, редактируются и удаляются. Также мы можем осуществить поиск и сортировку заметок. Обязательно посмотрите полный исходный код, ссылку на который я привел в конце статьи, чтобы лучше понять как все устроено.

Благодарности

Конечно же, нельзя забывать о людях, которые помогли мне при написании статьи. Хотел бы выразить благодарность читателям Юрию Шмакову (@senneco) за помощь с его библиотекой Moxy и за помощь по другим вопросам. Также, хочу сказать спаcибо сотруднику JetBrains Роману Белову (@belovrv) за ревью статьи и за предоставленный код на Anko.

Заключение

Надеюсь, эта статья смогла убедить вас в том, что писать приложения на Kotlin не трудно, а может даже и легче, чем на Java. Конечно же, могут встречаться и баги, которые сотрудники JetBrains достаточно быстро фиксят. Если у вас появились какие-либо вопросы, вы можете задать их напрямую разработчикам на Slack-канале [26]. Также вы можете почитать статьи о разработке на Kotlin здесь [27].

Исходный код проекта: Notelin [28].

Автор: ImangazalievM

Источник [29]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/android/119241

Ссылки в тексте:

[1] Anko: https://github.com/Kotlin/anko

[2] Настройка Android Studio: #ide-configuring

[3] Что будем писать?: #what-will-write

[4] Структура приложения: #application-structure

[5] Создаем Model: #creating-model

[6] Экран с заметками: #main-screen

[7] Создаем MainView и MainPresenter: #creating-main-vp

[8] Реализуем поиск по заметкам: #notes-search

[9] Реализуем сортировку заметок: #notes-sorting

[10] Экран с содержанием заметки: #note-screen

[11] Создаем NoteView и NotePresenter: #creating-note-vp

[12] Реализуем сохранение заметки: #note-saving

[13] Благодарности: #thanks

[14] Заключение: #conclusion

[15] здесь: http://java-help.ru/kotlin-introduction/

[16] Kotlin 1.0.2 EAP: https://discuss.kotlinlang.org/t/kotlin-1-0-2-eap/1581/1

[17] ссылке: http://java-help.ru/activeandroid-orm-review/

[18] Moxy: https://habrahabr.ru/post/276189/

[19] KAndroid: https://github.com/pawegio/KAndroid

[20] kapt: http://blog.jetbrains.com/kotlin/2015/06/better-annotation-processing-supporting-stubs-in-kapt/

[21] NoteWrapper: https://github.com/ImangazalievM/Notelin/blob/master/app/src/main/kotlin/imangazaliev/notelin/mvp/models/NoteWrapper.kt

[22] MvpAppCompatActivity: https://github.com/ImangazalievM/Notelin/blob/master/app/src/main/kotlin/imangazaliev/notelin/mvp/common/MvpAppCompatActivity.java

[23] FloatingActionButton: https://github.com/makovkastar/FloatingActionButton

[24] здесь: https://kotlinlang.org/docs/reference/extensions.html

[25] this@MainActivity: https://kotlinlang.org/docs/reference/this-expressions.html

[26] Slack-канале: https://kotlinlang.slack.com/

[27] здесь: http://java-help.ru/category/android-kotlin/

[28] Notelin: https://github.com/ImangazalievM/Notelin

[29] Источник: https://habrahabr.ru/post/275255/