|
Проект погода (начало): геолокация, http(s)-запросы, разбор json, ImageView.
|
Содержание
|
Проект погода (продолжение): SplashScreen (заставка). Выбор города. Выбор и отображение массива значений (почасовая, ежедневная)
|
Проект погода (продолжение): SplashScreen (заставка). Выбор города. Выбор и отображение массива значений (почасовая, ежедневная). Разбор XML.
Содержание
SplashScreen
Если приложение долго грузится (запрос геолокации или "тяжёлых" данных из сети), то принято при запуске показывать заставку (SplashScreen).
Есть два варианта:
- Рисуют дополнительную activity с заставкой и запускают её первой.
- Прямо на основной activity рисуют ImageView поверх всех элементов и скрывают её, когда необходимость в ней исчезает.
Рассмотрим второй вариант (при условии что у нас ConstraintLayout):
Я в качестве заставки буду показывать фон:
первым элементом кладем ImageView с картинкой, задав ему режим растягивания на весь экран android:scaleType="fitXY" и Z-индекс android:elevation="999dp"
<ImageView
android:scaleType="fitXY"
android:elevation="999dp"
android:id="@+id/splash"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/splash" />
в коде главного окна в момент, когда нужно убрать заставку меняем Z-индекс заставки
val splash = findViewById<ImageView>(R.id.splash)
splash.elevation = 0F
чтобы картинка нам не мешалась при разработке, мы можем в дизайнере оставить Z-индекс 0, а в конструкторе окна задать 999F
Тут надо помнить, что в вёрстке указываются dp, а в коде float
Выбор города
В принципе список можно вывести прямо в главном окне (на форму кинуть ListView и с помощью атрибута elevation поместить его поверх всех элементов), но в таком случае не будет реализован выход по кнопке Назад, что может сказаться на юзабилити.
Поэтому, будем реализовывать "классический" вариант со списком в отдельном activity. Заодно научимся запускать дополнительные activity и получать от них ответ.
Создаем новую форму (Activity) с именем CityListActivity

На главное окно добавляем кнопу перехода на экран выбора города и обработчик для нее:
startActivityForResult(
Intent(this, CityListActivity::class.java),
1)
Здесь startActivityForResult - метод запуска нужной activity.
На форму CityListActivity кидаем вертикальный LinearLayout, в него TextView (для заголовка "Выберите город") и ListView. ListView присваиваем id cityList

В классе CityListActivity
создаем массив городов
private var cityNames = arrayOf(
"Moscow",
"Yoshkar-Ola",
"Kazan"
)
в конструкторе получаем указатель на ListView
cityListView = findViewById(R.id.cityList)
задаем для списка ArrayAdapter. ArrayAdapter связывает массив данных с шаблоном элемента списка.
cityListView.adapter = ArrayAdapter(
this,
R.layout.city_list_item,
cityNames
)
Android Studio покажет ошибку, что не знает что такое city_list_item - в контекстном меню добавляем реализацию:

Внимание! RootElement поменять на TextView

Созданный шаблон можно настроить (установить высоту, добавить границы...)
задаем обработчик клика по элементу списка
cityListView.setOnItemClickListener { parent, view, position, id ->
// получаем название города
val cityName = cityNames[position]
// запоминаем выбранное название города в параметрах
val newIntent = Intent()
newIntent.putExtra("cityName", cityName)
setResult(RESULT_OK, newIntent)
// заверщаем текущий activity
finish();
}
В классе главного окна для получения результата выбора реализуем метод onActivityResult
@SuppressLint("MissingSuperCall")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (data == null) {
return
}
val name = data.getStringExtra("cityName")
// тут запускаем http-запрос по имени города
}
Метод onActivityResult гугл объявил устаревшим (deprecated), и в IDE он помечается как ошибка - надо в контекстном меню "More action..." выбрать "Supress: add...". Перед методом будет добавлена аннотация @SuppressLint("MissingSuperCall").
Выделение лямбда-выражения в отдельную переменную
Для обработки результатов мы пользовались такой конструкцией - лямбда выражение передавали сразу в метод requestGET.
HTTP.requestGET(url) {result, error ->
if(result != null) {
val json = JSONObject(result)
val wheather = json.getJSONArray("weather")
val icoName = wheather.getJSONObject(0).getString("icon")
val temp = json.getJSONObject("main").getDouble("temp")
runOnUiThread {
textView.text = json.getString("name")
}
...
}
}
Но теперь тот же код будет использоваться для получения погоды по городу. Поэтому имеет смысл вынести код в отдельную переменную и использовать её в обоих вызовах:
// callback - свойство класса, объявляется в теле класса
private val callback: (result: String?, error: String) -> Unit = {result, error ->
if(result != null) {
val json = JSONObject(result)
val wheather = json.getJSONArray("weather")
val icoName = wheather.getJSONObject(0).getString("icon")
val temp = json.getJSONObject("main").getDouble("temp")
runOnUiThread {
textView.text = json.getString("name")
}
...
}
}
...
// при запросе погоды используем переменную, объявленную выше
HTTP.requestGET(url, callback)
Получение и разбор массива данных. Вывод списка на экран.
Для начала определимся со структурой формы:
Всё окно разбито по вертикали на три блока (указаны стрелками на рисунке ниже)
- первый блок - детальная информация о выбранной погоде
- второй блок - горизонтальный список кратких данных о погоде
- третий блок - панель с кнопками (пока у нас там только "Поиск города", но может ещё что-то придумаем)

Класс погода
Для хранения массива полученных данных нам нужно описать структуру элемента списка. Для этого в котлине есть data class - класс, который содержит только свойства.
Выглядит он примерно так (каждый класс жедательно заворачивать в отдельный файл)
data class Weather (
val dt: Int,
val mainTemp: Double,
val mainHumidity: Int,
val weatherIcon: String,
val weatherDescription: String,
val windSpeed: Double,
val windDeg: Int,
val dtTxt: String
)
Заполнение массива данных о погоде
С моим бесплатным аккаунтом на openweathermap кроме текущих данных можно запросить только список за 5 дней:
### Запрос погоды за 5 дней https://openweathermap.org/forecast5
GET https://api.openweathermap.org/data/2.5/forecast?lat={{lat}}&lon={{lon}}&appid={{token}}&lang=ru&units=metric
Объявим в классе главного окна массив данных о погоде
private val weatherList = ArrayList<Weather>()
Получаем и заполняем массив
val url = "https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&appid=${token}&lang=ru&units=metric"
HTTP.requestGET(url) {result, error ->
if(result != null) {
// перед заполнением очищаем список
weatherList.clear()
val json = JSONObject(result)
val list = json.getJSONArray("list")
// перебираем json массив
for(i in 0 until list.length()){
val item = list.getJSONObject(i)
val weather = item.getJSONArray("weather").getJSONObject(0)
// добавляем в список новый элемент
weatherList.add(
Weather(
item.getInt("dt"),
item.getJSONObject("main").getDouble("temp"),
item.getJSONObject("main").getInt("humidity"),
weather.getString("icon"),
weather.getString("description"),
item.getJSONObject("wind").getDouble("speed"),
item.getJSONObject("wind").getInt("deg"),
item.getString("dt_txt")
)
)
}
runOnUiThread {
// уведомляем визуальный элемент, что данные изменились
dailyInfoRecyclerView.adapter?.notifyDataSetChanged()
}
}
else
Log.d("KEILOG", error)
}
Вывод списка (RecyclerView)
RecyclerView - рекомендованный способ вывода списков (с его помощью можно было теоретически заполнить и список городов, но он для этого слишком монструозный). Его особенность в том, что визуальные элементы списка существуют только при отображении на экране. При выходе за экран визуальный элемент удаляется, перед появлением создается заново. Таким образом экономится память.
Итак, на форме у нас уже лежит элемент RecyclerView (№2)
В класс главного окна добавим переменную для связи с элементом RecyclerView
private lateinit var dailyInfoRecyclerView: RecyclerView
Затем в конструкторе её инициализируем и назначаем layoutManager и adapter
dailyInfoRecyclerView = findViewById(R.id.dailyInfoRecyclerView)
// назначаем менеджер разметки
dailyInfoRecyclerView.layoutManager = LinearLayoutManager(this, RecyclerView.HORIZONTAL, false)
// создаем адаптер
val weatherAdapter = WeatherAdapter(weatherList, this)
// при клике на элемент списка показать подробную информацию (сделайте сами)
weatherAdapter.setItemClickListener { weather ->
Log.d("KEILOG", "Click on Weather item")
}
dailyInfoRecyclerView.adapter = weatherAdapter
Класс WeatherAdapter мы должны написать сами. Я положил его в шпаргалки.
Разметка для элемента списка (не полная)
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:id="@+id/container"
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="100dp"
android:layout_height="match_parent">
<ImageView
android:id="@+id/weather_icon"
android:layout_width="match_parent"
android:layout_height="100dp"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Температура"
/>
<TextView
android:id="@+id/weather_temp"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
Перфекционизм
Информация в RecyclerView может затеряться на ярком фоне. Чтобы этого не происходило можно либо задать для RecyclerView фон с заливкой каким-то цветом, либо, что-бы не полностью скрывать наш красивый фон, сделать этот фон полупрозрачным.
Для задания полупрозрачности элемента используется тег alpha, но с ним тоже не всё хорошо - полупрозрачным становится и всё содержимое элемента.
Есть другой вариант: задать в качестве фона drawable ресурс, которому заливку указать с альфа-каналом:
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android">
<solid
android:color="#80FFFFFF"/>
</shape>
Доработка SplashScrin-а
По разным причинам данные мы можем и не получить. Чтобы наша заставка вечно не висела на экране приделаем таймер.
В конструктор добавляем таймер с обратным отсчётом, в параметрах которого указываем макcимальное время ожидания и интервал между тиками. На каждый тик срабатвает метод onTick, в котором мы можем принудительно закрыть таймер. Если за макcимальное время ожидания таймер не закроют в onTick, то сработает метод onFinish и таймер завершит свою работу.
На уровне класса нужно объявить переменные counter=0 и ready=false
object : CountDownTimer(5000,1000){
override fun onTick(millisUntilFinished: Long) {
// заставляем пялиться на нашу заставку как минимум 3 секунды
counter++
if(counter>3 && ready){
// данные получены - скрываем заставку
splash.elevation = 0F
this.cancel()
}
}
override fun onFinish(){
splash.elevation = 0F
}
}.start()
Не забудьте при получении данных установить ready=true. Хотя ничего страшного не произойдёт, если забудете - просто будут смотреть на заставку чуть подольше.
Вывод сообщений
В шпоры положил
В принципе тут простой телескопический конструктор
AlertDialog.Builder(this)
.setTitle("Заголовок")
.setMessage("Текст сообщения")
.setPositiveButton("OK", null)
.create()
.show()
Разбор XML
Маловероятно, мо может встретиться XML-формат в данных. Разберёмся с ним на примере погоды. В URL добавьте параметр &mode=xml.
Придёт примерно такой ответ:
<?xml version="1.0" encoding="UTF-8"?>
<weatherdata>
<location>
<name>Йошкар-Ола</name>
<type></type>
<country>RU</country>
<timezone>10800</timezone>
<location altitude="0" latitude="56.6384" longitude="47.893" geobase="geonames" geobaseid="466806"></location>
</location>
<credit></credit>
<meta>
<lastupdate></lastupdate>
<calctime>0</calctime>
<nextupdate></nextupdate>
</meta>
<sun rise="2021-11-16T04:30:05" set="2021-11-16T12:36:33"></sun>
<forecast>
<time from="2021-11-16T15:00:00" to="2021-11-16T18:00:00">
<symbol number="600" name="небольшой снег" var="13n"></symbol>
<precipitation probability="0.69" unit="3h" value="0.28" type="snow"></precipitation>
<windDirection deg="275" code="W" name="West"></windDirection>
<windSpeed mps="5.56" unit="m/s" name="Moderate breeze"></windSpeed>
<windGust gust="13.37" unit="m/s"></windGust>
<temperature unit="celsius" value="-1.58" min="-2.05" max="-1.58"></temperature>
<feels_like value="-7.22" unit="celsius"></feels_like>
<pressure unit="hPa" value="1015"></pressure>
<humidity value="94" unit="%"></humidity>
<clouds value="пасмурно" all="99" unit="%"></clouds>
<visibility value="651"></visibility>
</time>
...
</forecast>
</weatherdata>
Для работы с XML в андроиде есть класс XmlPullParser. Разберём с его помощью ответ сервера.
Во-первых, создадим сам объект парсера:
HTTP.requestGET(url) {result, error ->
if(result != null) {
val factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
val parser = factory.newPullParser()
parser.setInput(StringReader(result))
...
А дальше идёт тупой перебор тегов в цикле:
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> ...
XmlPullParser.END_TAG -> ...
}
parser.next()
}
Т.е. пока не достигнем конца документа смотрим что у нас в текущем элементе:
- START_DOCUMENT – начало документа
- START_TAG – начало тега
- TEXT – содержимое элемента
- END_TAG – конец тега
- END_DOCUMENT – конец документа
Мы из всего этого многообразия будем использовать только начало и конец тега.
По началу тега мы смотрим в каком теге находимся и, при необходимости, считываем текст или атрибут в локальную переменную. А по концу тега создаём объект Weather из накопленных данных и записываем его в массив.
Пример (как обычно не полный, но достаточный для понимания):
В переменных topTag и subTag я запоминаю текущую позицию в иерархии XML. В этом документе они в принципе не нужны, но для примера проверок сделал.
var cityName = ""
var topTag = ""
var subTag = ""
var dt_txt = ""
var description: String = ""
var icon: String = ""
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when (parser.eventType) {
XmlPullParser.START_TAG -> {
when (parser.name) {
// в качестве начальных тегов нам интересны "location" и "forecast"
"location", "forecast" -> topTag = parser.name
"name" -> {
// внутри "location" в теге "name" читаем название города
if(topTag=="location") cityName = parser.nextText()
}
"time" -> {
if (topTag=="forecast") {
// внутри "forecast" нам интересно содержимое "time"
subTag = parser.name
// и сразу считываем СТРОКОВУЮ дату
dt_txt = parser.getAttributeValue(null, "from").toString()
}
}
"symbol" -> {
if(subTag=="time"){
description = parser.getAttributeValue(null, "name").toString()
icon = parser.getAttributeValue(null, "var").toString()
}
}
// тут мне как обычно лень стало расписывать остальные теги
}
}
XmlPullParser.END_TAG -> {
when (parser.name) {
"time" -> {
// по закрытию тега "time" пишем погоду в массив
weatherList.add(
Weather(
0,
0.0,
0,
icon,
description,
0.0,
0,
dt_txt
)
)
}
}
}
}
parser.next()
}
Задание
- вывести в элементы списка остальную информацию о погоде (формат XML)
- при обновлени списка и при клике на элемент списка выводить в верхнюю часть детальную информацию о погоде
- вставить алерты на все нештатные ситуации (не получены координаты, нет ответа от сервера...)
- завернуть всю работу с интернетом в исключительные ситуации (try..catch..) с выводом алертов
Это интересно
https://habr.com/ru/company/true_engineering/blog/267497/