Коротко: 4 мобильных приложения

Не так давно наткнулся на одну занимательную книгу по Андроид разработке на языке Котлин. Это 4 издание «Android. Программирование для профессионалов» 2020 год. Я не только прочитал её на одном дыхании, но и на практике повторил большинство описанных в ней примеров приложений. Хочу показать, что получилось. Я выбрал 4 мобильных приложения, различных по используемым в них технологиям разработки.

4 мобильных приложения — содержание

Список новостей

Первое приложение запрашивает с определённого сервера актуальные новости и выводит их список. Для вывода применяется RecyclerView с пользовательским представлением данных каждой записи. По клику пользователя на конкретной записи можно перейти в выбранный из установленных браузеров для просмотра новости целиком. После запуска приложение выглядит так, как на рис. 1 :

рис. 1 Список новостей

Для отображения информации о каждой новости используем макет разметки, как в коде ниже :

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="8dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="horizontal">

        <ImageView
            android:id="@+id/item_thumb"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_weight="1"
            android:layout_marginEnd="8dp"
            app:srcCompat="@mipmap/ic_launcher" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:padding="4dp"
            android:orientation="vertical">

            <TextView
                android:id="@+id/item_title"
                android:lines="1"
                android:textColor="#4040D0"
                android:background="#FFE0E0"
                android:textSize="24sp"
                android:textStyle="bold"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="Title" />

            <TextView
                android:id="@+id/item_descr"
                android:lines="3"
                android:background="#FFE0E0"
                android:textSize="16sp"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="Descr" />
        </LinearLayout>
    </LinearLayout>
</FrameLayout>

Слева картинка, справа 2 TextView друг под другом. Макет главной формы целиком отдан RecyclerView.

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/acti_recView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</android.support.constraint.ConstraintLayout>

Не забываем в файле манифеста разрешить использование интернета

<uses-permission android:name="android.permission.INTERNET"/>

А в файле build.gradle прописать использование зависимостей (библиотек) :

    implementation 'com.android.support:recyclerview-v7:28.0.0'

    implementation 'io.reactivex.rxjava2:rxjava:2.1.7'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
    implementation 'io.reactivex.rxjava2:rxkotlin:2.2.0'

    implementation 'com.google.code.gson:gson:2.8.9'
    implementation 'com.squareup.picasso:picasso:2.5.2' 

Для выполнения запросов к серверу будем использовать функцию createRequest :

import io.reactivex.Observable
import java.net.HttpURLConnection
import java.net.URL

fun createRequest(url : String)= Observable.create<String> {
    val urlConnection = URL(url).openConnection() as HttpURLConnection
    try {
        urlConnection.connect() // само обращение в сеть

        if (urlConnection.responseCode != HttpURLConnection.HTTP_OK) // проверка результата, что он 200
            it.onError(RuntimeException(urlConnection.responseMessage))
        else {
            val str = urlConnection.inputStream.bufferedReader()
                .readText() // читаем urlConnection как текст
            it.onNext(str)
        }
    } finally {
        urlConnection.disconnect()
    }
}

Получение ответа на сетевой запрос может занимать длительное время. Работу с сетью нужно вынести в отдельный поток, а результат нужно отображать в элементы макета только из главного потока. Применим для реализации этого фреймворк RxJava, который позволяет гибко управлять асинхронностью выполнения запросов, а также переключать выполнение операций в различные потоки. Сетевой запрос createRequest будет выполняться в выделяемом планировщиком потоков ввода/вывода отдельном потоке. Возвращаемый результат после преобразования к нужному формату будет обрабатываться в главном потоке :

val obs = createRequest(s1_url)
                .map { Gson().fromJson(it, Feed::class.java) }
.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())

Для отображения получаемых данных в RecyclerView создадим класс RecAdapter, а для наполнения его данными — класс RecHolder. Весь код файла MainActivity.kt приведён ниже :

import android.content.Intent
import android.net.Uri
import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.RecyclerView
import android.support.v7.widget.RecyclerView.Recycler
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import com.google.gson.Gson
import com.squareup.picasso.Picasso
import io.reactivex.Observable
import io.reactivex.Scheduler
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import java.net.HttpURLConnection
import java.net.URL

class MainActivity : AppCompatActivity() {

    lateinit var vText : TextView
    lateinit var vRecView : RecyclerView
    var request : Disposable? = null

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

        vRecView = findViewById<RecyclerView>(R.id.acti_recView)

        val s1_url : String = "https://api.rss2json.com/v1/api.json?rss_url=https%3A%2F%2Flenta.ru%2Frss"
        val s2_url : String = "https://api.rss2json.com/v1/api.json?rss_url=http%3A%2F%2Ffeeds.bbci.co.uk%2Fnews%2Frss.xml"
            val obs = createRequest(s1_url)
                .map { Gson().fromJson(it, Feed::class.java) }
.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())

        request = obs.subscribe({
            showRecView(it.items)
            for (item in it.items) {
                Log.w("Test", "title: ${item.title}")
            }
        }, {
            Log.e("Test", "", it)
        })
    }

    override fun onDestroy() {
        request?.dispose()
        super.onDestroy()
    }

    fun showRecView(feedList: ArrayList<FeedItem>){
        vRecView.adapter = RecAdapter(feedList)
        vRecView.layoutManager = LinearLayoutManager(this)
    }
}

data class Feed (
    val items: ArrayList<FeedItem>
)

data class FeedItem (
    val title:String,
    val link:String,
    val thumbnail:String,
    val description:String,
    val enclosure: Enclosure
)

data class Enclosure(
    val link: String,
    val type: String,
    val length: Int
)

class RecAdapter(val items: ArrayList<FeedItem>) : RecyclerView.Adapter<RecHolder>(){
    override fun onCreateViewHolder(parent: ViewGroup, p1: Int): RecHolder {
        val inflater = LayoutInflater.from(parent!!.context)

        val view = inflater.inflate(R.layout.item_list, parent, false)

        return RecHolder(view)
    }

    override fun onBindViewHolder(holder: RecHolder, position: Int) {
        val item = items[position]
        holder?.bind(item)
    }

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

}

class RecHolder(view : View) : RecyclerView.ViewHolder(view){

    fun bind(item:FeedItem){
        val vTitle = itemView.findViewById<TextView>(R.id.item_title)
        val vDescr = itemView.findViewById<TextView>(R.id.item_descr)
        val vThumb = itemView.findViewById<ImageView>(R.id.item_thumb)

        vTitle.text = item.title
        vDescr.text = item.description

        val thumbURI: String = item.enclosure.link

        try {
            Log.w("Test", "thumbURI: ${thumbURI.toString()}")
            Picasso.with(vThumb.context).load(thumbURI).into(vThumb)

        } catch (e: Exception) {
            Log.e("ImgLoadingError", e.message ?: "null")
        }

        itemView.setOnClickListener {
            val intent = Intent(Intent.ACTION_VIEW)
            intent.data = Uri.parse(item.link)
            vThumb.context.startActivity(intent)
        }
    }
}

Классы Feed, FeedItem и Enclosure описывают модель возвращаемых с сервера данных. Правильнее их вынести в отдельный файл. В классе RecHolder добавлена обработка события клика по записи о новости. Здесь активируется интент просмотра в браузере, которому в качестве параметра передается ссылка на страницу новости. Обработка клика показана на рис. 2 :

рис. 2 Переход в браузер для просмотра

Таблица звуков

Мобильное приложение BeatBox отображает сетку кнопок, отвечающих за воспроизведение ограниченного числа звуков при клике по кнопке. Набор звуков содержится в ресурсах приложения. Нужно одновременно проигрывать несколько звуков. Приложение BeatBox построено на базе архитектуры MVVM. Внешний вид приложения показан на рис. 3

рис. 3 Таблица звуков

Для отображения набора кнопок-звуков используем RecyclerView, применив GridLayoutManager, и библиотеку компонентов архитектуры Jetpack под названием data binding (связывание / привязка данных) для привязки данных. Главное окно приложения и есть RecyclerView. Макет для каждого элемента-кнопки :

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <data>
        <variable
            name="viewModel"
            type="com.bignerdranch.android.beatbox.SoundViewModel"/>
    </data>
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="8dp">
        <Button
            style="@style/BeatBoxButton"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="center"
            android:onClick="@{() -> viewModel.onButtonClicked()}"
            android:text="@{viewModel.title}"
            tools:text="Sound name"/>
    </FrameLayout>
</layout>

Наши звуки — это wav файлы, которые лежат в папке assets. Класс модели минимален :

private const val WAV = ".wav"

class Sound(val assetPath: String, var soundId: Int? = null) {
    val name = assetPath.split("/").last().removeSuffix(WAV)
} 

Класс модели представления связывает модель и представление :

import androidx.databinding.BaseObservable
import androidx.databinding.Bindable

class SoundViewModel(private val beatBox: BeatBox)  : BaseObservable() {

    var sound: Sound? = null
        set(sound) {
            field = sound
            notifyChange()
        }

    @get:Bindable
    val title: String?
        get() = sound?.name

    fun onButtonClicked() {
        sound?.let {
            beatBox.play(it)
        }
    }
}

Для воспроизведения звуков будем использовать класс SoundPool, который позволяет загрузить в память большой набор звуков и управлять их максимальным количеством, воспроизводимым одновременно. Вся работа по загрузке и воспроизведению звуков идёт в классе BeatBox :

import android.content.res.AssetFileDescriptor
import android.content.res.AssetManager
import android.media.SoundPool
import android.util.Log
import java.io.IOException

private const val TAG = "BeatBox"
private const val SOUNDS_FOLDER = "sample_sounds"
private const val MAX_SOUNDS = 5

class BeatBox(private val assets: AssetManager) {
    val sounds: List<Sound>
    private val soundPool = SoundPool.Builder().setMaxStreams(MAX_SOUNDS).build()

    init {
        sounds = loadSounds()
    }

    fun play(sound: Sound) {
        sound.soundId?.let {
            soundPool.play(it, 1.0f, 1.0f, 1,0, 1.0f)
        }
    }

    fun release() {
        soundPool.release()
    }

    private fun loadSounds(): List<Sound> {
        val soundNames: Array<String>
        try {
            soundNames = assets.list(SOUNDS_FOLDER)!!
        } catch (e: Exception) {
            Log.e(TAG, "Could not list assets", e)
            return emptyList()
        }
        val sounds = mutableListOf<Sound>()
        soundNames.forEach { filename ->
            val assetPath = "$SOUNDS_FOLDER/$filename"
            val sound = Sound(assetPath)
            try {
                load(sound)
                sounds.add(sound)
            } catch (ioe: IOException) {
                Log.e(TAG, "Cound not load sound $filename", ioe)
            }
        }
        return sounds
    }

    private fun load(sound: Sound) {
        val afd: AssetFileDescriptor = assets.openFd(sound.assetPath)
        val soundId = soundPool.load(afd, 1)
        sound.soundId = soundId
    }
}

В коде класса MainActivity связываем список звуков с RecyclerView и обеспечиваем его работу с помощью внутренних классов SoundAdapter и SoundHolder :

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.bignerdranch.android.beatbox.databinding.ActivityMainBinding
import com.bignerdranch.android.beatbox.databinding.ListItemSoundBinding

class MainActivity : AppCompatActivity() {
    private lateinit var beatBox: BeatBox

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

        beatBox = BeatBox(assets)
 
        val binding: ActivityMainBinding =
            DataBindingUtil.setContentView(this, R.layout.activity_main)
        binding.recyclerView.apply {
            layoutManager = GridLayoutManager(context, 3)
            adapter = SoundAdapter(beatBox.sounds)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        beatBox.release()
    }

    private inner class SoundHolder(private val binding: ListItemSoundBinding) :
        RecyclerView.ViewHolder(binding.root) {

        init {
            binding.viewModel = SoundViewModel(beatBox)
        }

        fun bind(sound: Sound) {
            binding.apply {
                viewModel?.sound = sound
                executePendingBindings()
            }
        }
    }

    private inner class SoundAdapter(private val sounds: List<Sound>) :
        RecyclerView.Adapter<SoundHolder>() {
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int):
                SoundHolder {
            val binding = DataBindingUtil.inflate<ListItemSoundBinding>(
                    layoutInflater,
                    R.layout.list_item_sound,
                    parent,
                    false
                )
            return SoundHolder(binding)
        }
        override fun onBindViewHolder(holder: SoundHolder, position: Int) {
            val sound = sounds[position]
            holder.bind(sound)
        }
        override fun getItemCount() = sounds.size
    }
}

Подводя итоги, для этого простого с виду приложения всё оказалось очень не просто «под капотом».

Рисуем прямоугольники

В этом приложении пользователь рисует прямоугольники, прикасаясь к экрану и перемещая палец. Результат выглядит примерно так, как показано на рис. 4.

рис. 4 Рисуем прямоугольники

В созданной пустой activity с именем DragAndDrawActivity, унаследованной от AppCompatActivity, мы видим обычный макет с одним фрагментом.

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle

class DragAndDrawActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_drag_and_draw)
    }
}

Все отображение и обработка касаний будет реализовано в классе BoxDrawingView, который указан в макете :

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

Для хранения данных о каждом прямоугольнике определим класс Box :


import android.graphics.PointF

class Box(val start: PointF) {
    var end: PointF = start
    val left: Float
        get() = Math.min(start.x, end.x)
    val right: Float
        get() = Math.max(start.x, end.x)
    val top: Float
        get() = Math.min(start.y, end.y)
    val bottom: Float
        get() = Math.max(start.y, end.y)
}

В классе BoxDrawingView, являющимся наследником базового класса View, будем обрабатывать событие onTouchEvent касания экрана и перерисовывать все прямоугольники, сохраняемые в массиве boxen. Когда палец отрывается от экрана, то считаем формирование нового прямоугольника законченным. Он добавляется в список и вызывается принудительная перерисовка всей формы. Код класса ниже :

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PointF
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.View

private const val TAG = "BoxDrawingView"

class BoxDrawingView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {
    private var currentBox: Box? = null
    private val boxen = mutableListOf<Box>()
    private val boxPaint = Paint().apply {
        color = 0x22ff0000.toInt()
    }
    private val backgroundPaint = Paint().apply {
        color = 0xfff8efe0.toInt()
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        val current = PointF(event.x, event.y)
        var action = ""
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                action = "ACTION_DOWN"
                // Сбросить состояние объекта
                currentBox = Box(current).also {
                    boxen.add(it)
                }
            }
            MotionEvent.ACTION_MOVE -> {
                action = "ACTION_MOVE"
                updateCurrentBox(current)
            }
            MotionEvent.ACTION_UP -> {
                action = "ACTION_UP"
                updateCurrentBox(current)
                currentBox = null
            }
            MotionEvent.ACTION_CANCEL -> {
                action = "ACTION_CANCEL"
                currentBox = null
            }
        }
        Log.i(TAG, "$action at x=${current.x}, y=${current.y}")
        return true
    }

    private fun updateCurrentBox(current: PointF) {
        currentBox?.let {
            it.end = current
            invalidate()
        }
    }

    override fun onDraw(canvas: Canvas) {
        // Заполнение фона
        canvas.drawPaint(backgroundPaint)
        boxen.forEach { box -> canvas.drawRect(box.left, box.top, box.right, box.bottom, boxPaint)}
    }
}

Такими методами можно на форме нарисовать что-то нестандартное и это открывает полёт для дизайнерской фантазии.

Солнечный закат

В этом приложении будет показан пример несложной анимации. Нарисуем сцену с солнцем в небе. При нажатии солнце опускается за горизонт, а небо окрашивается в закатный цвет. Все цвета добавим в файл colors.xml :

<color name="bright_sun">#fcfcb7</color>
<color name="blue_sky">#1e7ac7</color>
<color name="sunset_sky">#ec8100</color>
<color name="night_sky">#05192e</color>
<color name="sea">#224869</color>

Добавим в папку res/drawable/ круглый графический объект sun.xml для изображения круглого солнца :

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <solid android:color="@color/bright_sun" />
</shape>

Файл макета выглядит следующим образом :

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/scene"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <FrameLayout
        android:id="@+id/sky"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="0.61"
        android:background="@color/blue_sky">
        <ImageView
            android:id="@+id/sun"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_gravity="center"
            android:src="@drawable/sun" />
    </FrameLayout>
    <View
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="0.39"
        android:background="@color/sea" />
</LinearLayout>

Если скомпилировать и запустить наше приложение, то мы увидим картинку как на рис. 5. Осталось заставить солнце двигаться и небо менять свой цвет …

рис. 5 Солнце днем

Для имитации заката будем использовать объект ObjectAnimator. Можно перемещать что-то, поворачивать, изменять размеры и цвета. Чтобы анимация была плавнее можно использовать интерполяцию. Функцию анимации будем запускать по клику в окне приложения. В этой функции создадим 3 аниматора : для перемещения heightAnimator и изменения цвета солнца и неба sunsetSkyAnimator и nightSkyAnimator соответственно. Весь код ниже :

import android.animation.AnimatorSet
import android.animation.ArgbEvaluator
import android.animation.ObjectAnimator
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import android.view.animation.AccelerateInterpolator
import androidx.core.content.ContextCompat

class MainActivity : AppCompatActivity() {

    private lateinit var sceneView: View
    private lateinit var sunView: View
    private lateinit var skyView: View
    private val blueSkyColor: Int by lazy {
        ContextCompat.getColor(this, R.color.blue_sky)
    }
    private val sunsetSkyColor: Int by lazy {
        ContextCompat.getColor(this, R.color.sunset_sky)
    }
    private val nightSkyColor: Int by lazy {
        ContextCompat.getColor(this, R.color.night_sky)
    }

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

        sceneView = findViewById(R.id.scene)
        sunView = findViewById(R.id.sun)
        skyView = findViewById(R.id.sky)

        sceneView.setOnClickListener {
            startAnimation()
        }
    }

    private fun startAnimation() {
        val sunYStart = sunView.top.toFloat()
        val sunYEnd = skyView.height.toFloat()
        val heightAnimator = ObjectAnimator.ofFloat(sunView, "y", sunYStart, sunYEnd).setDuration(3000)
        heightAnimator.interpolator = AccelerateInterpolator()
        val sunsetSkyAnimator = ObjectAnimator.ofInt(skyView, "backgroundColor", blueSkyColor, sunsetSkyColor)
            .setDuration(3000)
        sunsetSkyAnimator.setEvaluator(ArgbEvaluator())
        val nightSkyAnimator = ObjectAnimator.ofInt(skyView, "backgroundColor", sunsetSkyColor, nightSkyColor)
            .setDuration(1500)
        nightSkyAnimator.setEvaluator(ArgbEvaluator())

        val animatorSet = AnimatorSet()
        animatorSet.play(heightAnimator)
            .with(sunsetSkyAnimator)
            .before(nightSkyAnimator)
        animatorSet.start()
    }
}
рис. 6 Солнце на закате

Как и планировалось после запуска солнце сползает в море, а небо меняет свой цвет — рис. 6.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *