Еще немного о кастомных интерфейсах в iOS приложениях

в 5:54, , рубрики: iOS, ios 5, lds, liquid drug store, кастом, кастомизация, приложение, приложения, разработка, разработка под iOS, метки: , , , , , , ,

Доброго врмени суток! Многие начинающие разработчики под iOS задумываются о том, как сделать интерфейс своего приложения красивым, функциональным и ничуть не медленнее, чем нативный. Начиная с iOS 5.0 нам доступны многие классные функции для кастомизации стандартных контролов, и использовать

[UIView setBackgroundColor:[UIColor colorWithPatternImage:someImage]];

нет никакой необходимости.

В Сети также накопилось достаточное количество туториалов, как придать стандартным контролам нужный вид. Есть библиотеки отдельных, ненативных контролов. Но что, если вам необходимо реализовать, например, такой интрефейс (обратите внимание на список — он имитирует лист внутри ленты и может скроллиться от низа до верха, при это двигаться по задумке должен сам лист, а не таблица внутри него):

Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях

Интерфейс этого приложения очень красивый и крайне удобный (ИМХО), а вот с технической стороны вопроса — более трудоемкий. Большинство приложений в AppStore не используют кастомизацию в таких масштабах. Тем не менее, в данном конкретном случае кастомизация, пусть и такая сильная, пошла лишь на пользу, и поэтому я решил рассказать о том, какие решения я применял для реализации такого дизайна.
Стоит отметить, что в скриншотах невозможно передать динамику и анимации, поэтому в конце статьи ссылка для загрузки (приложение полностью бесплатно и не содержит встроенных покупок).

Под катом примеры трюков, кода и немного справочной информации по сложной кастомизации.
Будьте осторожны! В проекте включен ARC.

Сплэш и плавный переход от него к приложению

Все знают, что при запуске приложения iOS, сначала выводится так называемый Splash, и только потом, после загрузки отбражется само приложение. Конечно, необходимо этот переход сделать как можно более плавным. Для этого есть два основных решения: сплэш представляет из себя скриншот первого экрана приложения, но без текста и активных контролов. Запустите на своем iPhone программу Телефон или Калькулятор, и вы поймете о чем я. Но что же делать, если первый экран приложения сам по себе загрузочный/системный? Например, при запуске приложение проверяет обновления базы данных:

Еще немного о кастомных интерфейсах в iOS приложениях

Ведь, согласитесь, в сплэше заголовок будет по центру, а не вверху, так как никаких других элементов пока больше нет:

Еще немного о кастомных интерфейсах в iOS приложениях

Я думаю, вряд ли вам и пользователям понравится резкая смена второго на первое.

Итак, задача: сделать плавный переход от сплэша к функционалу.
Решение:

Первый и самый очевидный шаг: небходимо, чтобы наш первый экран инициализировался и изначально отображался абсолютно так же, как и сплэш. Идеальной копии можно добиться разработкой сплэша непосредственно в контроллере в XCode, а потом сделать для него скрины в двух разрешениях (Retina и обычное). Затем поставить соответствующие картинки Default.png и начать думать над функционалом перехода.

Трюк: Разберем на примере такого функционала экрана загрузки: отображение заголовка, а под ним — либо кастомный прелоадер загрузки с текстом под ним, либо прогресс бар с текстом под ним, либо текст с двумя кнопками. Вот так:

Еще немного о кастомных интерфейсах в iOS приложениях

Алгоритм действий таков:

0) В Interface Builder расставляем заранее все контролы, которые только могут быть на нашем ViewController (как на картинке выше). Связываем их с соответствующими IBOutlet. Важно расставить контролы так, как они будут выглядеть, когда будут видимыми. После того как расставили, делаем им alpha=0;

1) Переопределяем

- (void)viewDidLoad

и запоминаем CGRect для каждого контрола:

okFrame = ok.frame;
cancelFrame = cancel.frame;

2) Программно высчитываем координаты тех мест, куда надо сдвинуть наши контролы, чтобы их не было видно. Это если вы хотите потом их анимированно показывать. У меня анимированно выезжают лишь кнопки, остальные контролы плавно исчезают или появляются на своих местах:

CGRect tempFrame;
    
tempFrame = ok.frame;
tempFrame.origin.x = self.view.frame.size.width + tempFrame.size.width;
[ok setFrame:tempFrame];
    
tempFrame = cancel.frame;
tempFrame.origin.x = 0 - tempFrame.size.width;
[cancel setFrame:tempFrame];

Как вы понимаете, таким образом наш контроллер отобразит только логотип в центре — точно так же, как и на сплэше. Это же отлично!

3) Ну а теперь нужно плавно поднять логотип выше(значение, к сожалению, можно подобрать только методом тыка), а также плавно отобразить прелоадер и текст. Обратите внимание, что здесь не нужно подключать целый Framework CoreAnimation. Есть более простой способ:

CGRect logoFrame = logo_launch.frame;//взяли фрейм логотипа
logoFrame.origin.y -= 60;//ну и подняли этот фрейм

[UIView beginAnimations:nil context:NULL];
[UIView setAnimationDuration:1.0];//длительность анимации в секундах
[UIView setAnimationCurve:UIViewAnimationCurveEaseInOut];//все что будет ниже этого
    
[logo_launch setFrame:logoFrame];
[activity_logo setAlpha:1.0];
[descLabel setAlpha:1.0];
    
[UIView commitAnimations];//но выше этого - будет отображаться анимированно с указанной длительностью и параметрами

4) PROFIT! Далее вы можете использовать эти анимации чтобы менять контролы: например, плавно убрали прелоадер и текст, сменили текст и плавно его опять показали, но уже с прогресс баром.

Я специально ждал момента, когда приложение выйдет в AppStore: результат нельзя отобразить скриншотами, но вы можете посмотреть, как это выглядит, в самом приложении.

Вы уже догадались, в чем польза решения расставлять контролы в IB сразу так, как они должны будут выглядеть на экране, несмотря на то, что потом руками придется их скрывать? Правильно — высчитать и поместить их за границы экрана гораздо проще, чем методом тыка определять, куда их надо будет поместить, плюс теперь мы можем не меняя ни строчки кода менять конечное положение контролов прямо в IB. Заказчик сменил положение логотипа? Не вопрос, говорим сколько это будет стоить и парой кликов мыши меняем положение согласно новому в эскизе.

Скругленные углы

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

Итак, задача: найти способ делать закгруленные углы у ЛЮБОГО контрола.
Решение:

Все достаточно просто, если знать, что у всех UIVIew есть свойство Clip Subviews
Берем любой наш контрол, помещаем в UIView, делаем размеры UIView минимальными и включаем ему свойство Clip Subviews:

Еще немного о кастомных интерфейсах в iOS приложениях

Ну а затем для соответствующего UIVIew — контейнера делаем

[mapContainer.layer setCornerRadius:10];

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

Кастомные UINavigationBar и UIToolBar, а точнее их отсутствие

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

Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях

Так что делаем так: берем прозрачный UIView и кладем в него все, что нужно. А у UINavigationController убираем UINavigationBar.

Вообще советую все группы элементов класть внутрь UIView. Помните пример анимации с помощью commitAnimations? Вот он почему-то с UIImageView не работает. Да и мало ли с чем еще. А с UIView — стабильно. Да и в IB слева все удет очень красиво сгруппировано.

Теперь можно их анимировать: у меня, например, сверху съезжает верхняя лента, а снизу вставляется лист в ленту.

Красивый динамический лист

Ну а теперь самое сложное и интересное. Для начала предлагаю посмотреть, как должен выгляеть этот лист:

Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях
Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях
Еще немного о кастомных интерфейсах в iOS приложениях Еще немного о кастомных интерфейсах в iOS приложениях

В динамике очень эффектно выглядит.

Очевидно, что раз лист можно двигать, размещать все придется в чем-то таком, что называется UIScrollView :) Но не все так просто.
Давайте разделим реализацию листа по контенту: статический и динамический.

Статический контент

Например, мы собираемся поместить на это лист заранее известный текст: это может быть раздел о программе или справки. Или другой пример: на листе располагается форма заполнения данных, известная заранее. В таком случае размещаем все в UIScrollView и делаем ему setContentSize такой, чтобы все влезло.

Вроде все просто, правда? А теперь задумаемся: что делать с фоном? Плодить кучу картинок для каждого листа? Растягивать-то имеющуюся картинку нельзя: там тени, текстура, скругленные углы! А нам еще потом для динамического контента делать, там вообще размер может быть любой…

Итак, срываю покровы:Подскажу об интересных свойствах UImageView:
для того, чтобы можно было сделать лист любой длины, имея в распоряжении лишь одну его картинку (на сплэше как раз она — минимальная), нужно поместить несколько UIImageView с одной и той же картинкой:

  • UIImageView со свойством contentMode = Top
  • UIImageView со свойством contentMode = Center
  • ...
  • UIImageView со свойством contentMode = Center
  • UIImageView со свойством contentMode = Bottom

У каждого UIImageView должна стоять птичка Clip Subviews! Это приницпиально!
Когда вы соберете такой пирог, подберите соответствующий размер компонентам, чтобы они склеились.
PROFIT! Сверху этих картинок размещайте все, что душе угодно, но — с прозрачным фоном. И потом все вместе запихните в UIScrollView, над которым располагается лента.

Динамический контент

А вот здесь все очень весело. Мы не можем слепить пирог из картинок, так как мы не знаем, сколько их будет.
На самом деле, можем. Только мы не будем делать это руками, мы это сделаем с помощью UITableView.

Нужно создать прототипы ячеек разных видов:

  • Cell Top
  • Cell Middle
  • Cell Bottom
  • Cell Single
  • Cell NoneData

И для каждой запрашиваемой возвращать нужную ячейку с нужным фоном:

Еще немного о кастомных интерфейсах в iOS приложениях

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    // Configure the cell...
    {
        if (indexPath.row == 0)//cell top or single
        {
            if ([tableView numberOfRowsInSection:0] == 1)//single
            {
                if (!emptyList)
                {
                    NSString *CellIdentifier = @"single";
                    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
                    //
                    if (searchMode)
                    {
                        ((UILabel*)[cell viewWithTag:100]).text = [NamesSearchList objectAtIndex:indexPath.row];
                    }
                    else
                    {
                        ((UILabel*)[cell viewWithTag:100]).text = [NamesList objectAtIndex:indexPath.row];
                    }
                    //
                    [((UIImageView*)[cell viewWithTag:500]) setHidden:NO];
                    [((UIActivityIndicatorView*)[cell viewWithTag:400]) setHidden:YES];
                    [((UIActivityIndicatorView*)[cell viewWithTag:400]) stopAnimating];
                    return cell;
                }
                else {
                    NSString *CellIdentifier = @"none";
                    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
                    return cell;
                }
            }
            else//top 
            {
                NSString *CellIdentifier = @"top";
                UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
                if (searchMode)
                {
                    ((UILabel*)[cell viewWithTag:100]).text = [NamesSearchList objectAtIndex:indexPath.row];
                }
                else
                {
                    ((UILabel*)[cell viewWithTag:100]).text = [NamesList objectAtIndex:indexPath.row];
                }
                [((UIImageView*)[cell viewWithTag:500]) setHidden:NO];
                [((UIActivityIndicatorView*)[cell viewWithTag:400]) setHidden:YES];
                [((UIActivityIndicatorView*)[cell viewWithTag:400]) stopAnimating];
                return cell;
            }
        }
        else {
            if (indexPath.row == ([tableView numberOfRowsInSection:0]-1))//cell bottom
            {
                NSString *CellIdentifier = @"bottom";
                UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
                if (searchMode)
                {
                    ((UILabel*)[cell viewWithTag:100]).text = [NamesSearchList objectAtIndex:indexPath.row];
                }
                else
                {
                    ((UILabel*)[cell viewWithTag:100]).text = [NamesList objectAtIndex:indexPath.row];
                }
                [((UIImageView*)[cell viewWithTag:500]) setHidden:NO];
                [((UIActivityIndicatorView*)[cell viewWithTag:400]) setHidden:YES];
                [((UIActivityIndicatorView*)[cell viewWithTag:400]) stopAnimating];
                return cell;
            }
            else {//cell middle
                NSString *CellIdentifier = @"middle";
                UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
                if (searchMode)
                {
                    ((UILabel*)[cell viewWithTag:100]).text = [NamesSearchList objectAtIndex:indexPath.row];
                }
                else
                {
                    ((UILabel*)[cell viewWithTag:100]).text = [NamesList objectAtIndex:indexPath.row];
                }
                [((UIImageView*)[cell viewWithTag:500]) setHidden:NO];
                [((UIActivityIndicatorView*)[cell viewWithTag:400]) setHidden:YES];
                [((UIActivityIndicatorView*)[cell viewWithTag:400]) stopAnimating];
                return cell;
            }
        }
    }
    return nil;
}

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

Динамический текст также реализуется через UITableView. Осталось лишь одно «но»: IB не даст вам размещать никакие другие контролы, кроме UITableView в UITableViewController.
Поэтому исопльзуем обычный контроллер с реализацией соответствующих протоколов и связываем нужную нам UITableView с File Owner:

@interface lds_byname : UIViewController <UITableViewDataSource, UITableViewDelegate>

Заключение

Я постарался рассмотреть самые сложные вопросы кастомизации интерфейса приложения для iOS. Если возникнут вопросы по не описанным аспектам, готов добавить их в статью. Или же другие, более рациональные и грамотные предложения.

Ссылка для загрузки в AppStore:Liquid Drug Store
Еще раз повторюсь: всю прелесть кастомного интерфейса можно оценить только в динамике.

P.S. Позволю себе немножко поспойлерить: я решил сделать доброе дело для: бесплатный iOS клиент Хабрахабр. В нем можно читать посты, добавлять их в избранное(хранится локально), смотреть Хабы и посты в них. На данный момент в AppStore всего один клиент — из этой статьи. Они умудрились сделать платным все, кроме просмотра постов в первом разделе. Я считаю, что это неправильнно, и сделал бесплатный, который будет постоянно поддерживаться и развиваться. На данный момент приложение находится на ревью и пробудет там, по моим преположениям, не меньше двух недель. Последнее время именно такой срок. Так что к концу сентября опубликую анонс/статью со ссылкой, которая уже готова. Прошу не воспринимать все это как рекламу или пиар: я не собираюсь на этом зарабатывать. Я сделал это для всех нас. А где еще как не на Хабре писать для клиент про него?

Немного скриншотов:
Лента
Хабы
Избранное
Просмотр поста 1
Просмотр поста 2

Надеюсь, все меня правильно поняли.

Автор: plasm

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js