Scroll Views внутри Scroll Views

в 3:05, , рубрики: Cocoa, iOS, layout, mobile development, разработка под iOS

В данной статье я хочу представить OLEContainerScrollView, который является потомком UIScrollView и позволяет вам добавлять несколько scroll views, таблиц (UITableView) или коллекций (UICollectionView) в один контейнер.

Возможное применение


Вы можете использоваться OLEContainerScrollView для достижения следующих целей:

  • Размещение несколько scroll views (или таблиц, или коллекций) один под другим так, чтобы при этом их обычное поведение при прокрутке не пострадало. В случае таблиц или коллекций речь идет о сохранении работоспособности механизма повторного использования ячеек.
  • Превращение одного сложного UITableViewDataSource или UICollectionViewDataSource в несколько простых источников данных путем разделения таблицы или коллекций, состоящей из нескольких секций, на несколько односекционных таблиц или коллекций, расположенных друг за другом.
  • Добавление заголовка или подвала (header или footer) над или под коллекцию без необходимости управлять их разметкой. В таком случае это будут простые UIScrollViews или UIViews.

Повторное использование ячеек в таблицах и коллекциях

Перед тем, как рассмотреть реализацию моего класса, давайте обратим внимание на то, как таблицы или коллекции вообще работают. Оба класса, UITableView и UICollectionView, являются потомками UIScrollView и ведут себя аналогично. Однако, ключевым отличием является то, что таблицы и коллекции повторно используют свои ячейки. Когда вид (view) прокручивается и ячейка выходит за пределы экрана, эта ячейка удаляется из иерархии видов (иными словами, ей посылается сообщение removeFromSuperview) и переносится в очередь для повторного использования (reuse queue). В то же время, перед тем, как новая ячейка должна появиться, таблица вызывает из очереди ожидания повторного использования свободную ячейку и повторного размещает ее на своей иерархии видов. Этот подход позволяет избежать значительного потребления памяти, минимизировать число дорогостоящих операций создания и размещения (allocation) в памяти новых видов и обеспечить максимально быструю прокрутку.

Scroll Views внутри Scroll Views

Иллюстрация повторного использования ячеек в UITableView. Невидимые ячейки удаляются из иерархии видов (обозначены синим пунктиром) и добавляются на вид таблицы перед отображение в результате прокрутки. Заметьте, что frame таблицы (светло-синий прямоугольник) меньше, чем размер контента (content size, обведён красной пунктирной линией), как это обычно и бывает у scroll view. Скачать видео (в формате H.264).

Простой подход к реализации контейнера

Размещение нескольких scroll view в одном общем контейнере, так же являющимся scroll view, — это довольно простая задача:

  • Создать UIScrollView, который будет контейнером для вложенный scroll view.
  • Добавить вложенные scroll views в контейнер. Это могут быть как простые scroll view, так и таблицы или коллекции.
  • Установить вложенным scroll view такие frame, чтобы они вмещали целиком их контент (contentSize). Установить положения scroll views один под другим.
  • Установить размер контента (contentSize) контейнера как сумму frames вложенных видов.

Таким образом, когда мы каждому вложенному scroll view устанавливаем размер frame больший либо равный размеру их контента, мы получаем ситуацию, в которой эти scroll view никогда не будут прокручиваться — единственным прокручивающимся объектом будет контейнер. Это позволяет избежать помех в обработке касаний между вложенными scroll view и их контейнером.

Эта схема, безусловно работает, но есть один существенный недостаток: чрезмерная трата памяти. Если вложенные scroll view являются таблицами или коллекциями, то они создают ячейку для каждой строки, потому что все ячейки в их понимации являются видимыми. Если коллекция содержит сотни или тысячи ячеек, тогда эффект будет по истине драматическим: прокрутка будет тормозить, а ваше приложение потребит всю доступную память на устройстве.

Scroll Views внутри Scroll Views

Пример простого подхода к реализации контейнера для нескольких scroll views. Две таблицы добавлены как подвиды (subviews) в один общий контейнер, который сам при этом является scroll view (прямоугольник с черной обводкой). Frames таблиц (светло-синий и светло-жёлтый прямоугольники) изменены так, чтобы полностью вмещать в себя контент каждой таблицы (красная пунктирная обводка). Заметьте, как это отразилось на повторном использовании ячеек — оно прекратилось, одновременно присутствуют все ячейки каждой строки, независимо от того, видима ли ячейка (внутри frame контейнера, черный прямоугольник) или нет. Скачать видео (в формате H.264).

OLEContainerScrollView

Теперь я расскажу о своём контейнере для scroll view. OLEContainerScrollView — это наследник UIScrollView, который автоматически упорядочивает вложенные в него виды в манере колоды или стопки (похоже на NSStackView в OS X). Этот контейнер работает со всеми типами видами, не только со scroll views, хотя scroll view он обрабатывает особым образом.

Добавление видов

Чтобы добавить вид в контейнер, необходимо использовать метод addSubviewToContainer:. Я был вынужден создать новые методы для добавления и удаления видов в контейнер, а не полагаться на существующую пару addSubview:/removeFromSuperview, потому что я хотел добавлять виды в приватный contentView, а не прямо в контейнер. Этот приём позволил избежать помех со стороны приватных подвидов самого UIScrollView, которые создаются для отображения индикаторов прокрутки, когда мы позже будем перебором вложенных видов подгонять их размеры.

Как только вид добавляется в контейнер, происходит следующее:

  • Если новый вид является экземпляром UIScrollView (или наследником), тогда у него отключается собственная прокрутка с помощью свойства subview.scrollEnabled = NO;. Так обработкой жестов прокрутки будет заниматься только наш контейнер.
  • Подписываемся на KVO уведомления (notifications) об изменении размеров вложенного вида. Для обычный UIViews наблюдаем за изменениями свойств frames и bounds. Для scroll view наблюдаем за изменениями размеров контента через свойство contentSize. Это необходимо сделать, чтобы впоследствии подгонять (relayout) вложенные виды при изменениях их размеров извне.

Выравнивание во время прокрутки

Во время прокрутки контейнер непрерывно выравнивает frames вложенных в него видов следующим способом:

  • Перебирает все вложенные виды в порядке из добавления и позиционирует их в стопку: каждый следующий вид после предыдущего. Ширина всех видов подгоняется под ширину контейнера так, чтобы вид умещался в контейнере1. Для обычных UIViews выделяется место в соответствии с высотой их frame. Для scroll view выделяется место, достаточное для того, чтобы уместить их контент, высота берется из contentSize.height. Это означает, что вид, следующий за scroll view, будет размещен в контейнере так, чтобы до него уместилось всё содержимое scroll view.
  • Размер контента контейнера состоит из размеров контента всех вложенных видов.

Это по-прежнему соответствует простому подходу, описанному ранее. Теперь же нам требуется выровнять frames всех scroll views в контейнере так, чтобы у них получился минимальный размер, который позволит заполнить область видимости (viewport) контейнера (в соответствии с bounds):

  • Для этого определим для каждого вложенного scroll view в контейнере2 как их области контента пересекаются с текущей областью видимости контейнера, и установим им frame в соответствии с этим. Это означает, что frame любого scroll view никогда не будет больше, чем размер контейнера, и что виды, которые не находятся в данный момент в области видимости, будут иметь frame с высотой равной 0.

Посмотрите на видео ниже, чтобы понять, как этот алгоритм работает. В начале первая таблица заполняет собой всю область видимости контейнера (отмечена черной обводкой) — frame таблицы (светло-синий прямоугольник) точно равен bounds контейнера. Вторая таблица при этом полностью находится вне области видимости — её frame имеет высоту 0, таблица невидима. В итоге пока нет необходимости создавать в ней ячейки (желтый пунктир).

Как только пользователь прокручивает содержимое контейнера, и вторая таблица выходит в область видимости, высота frame таблицы (светло-желтый прямоугольник) начинает увеличиваться от нижней границы области видимости до того момента, пока не сравняется с высотой контейнера. В то же самое время frame первой таблицы сжимается до нуля, пока таблица удаляется за пределы экрана. Обе таблицы могут использовать без ограничений все возможности повторного использования ячеек без ограничений и каких-либо условий.

Scroll Views внутри Scroll Views

Демонстрация работы OLEContainerScrollView. Скачать видео (в формате H.264).

Код

Интерфейс класса OLEContainerScrollView выглядит так:

Код

@interface OLEContainerScrollView : UIScrollView
- (void)addSubviewToContainer:(UIView *)subview;
- (void)removeSubviewFromContainer:(UIView *)subview;
@end

А вот реализация метода layoutSubviews, который выполняет всю работу:

@implementation OLEContainerScrollView

...

- (void)layoutSubviews
{
    [super layoutSubviews];
    
    // Translate the container view's content offset to contentView bounds.
    // This keeps the contentview always centered on the visible portion of the container view's
    // full content size, and avoids the need to make the contentView large enough to fit the
    // container view's full content size.
    self.contentView.frame = self.bounds;
    self.contentView.bounds = (CGRect){ self.contentOffset, self.contentView.bounds.size };
    
    // The logical vertical offset where the current subview (while iterating over all subviews)
    // must be positioned. Subviews are positioned below each other, in the order they were added
    // to the container. For scroll views, we reserve their entire contentSize.height as vertical
    // space. For non-scroll views, we reserve their current frame.size.height as vertical space.
    CGFloat yOffsetOfCurrentSubview = 0.0;
    
    for (UIView *subview in self.contentView.subviews)
    {
        if ([subview isKindOfClass:[UIScrollView class]]) {
            UIScrollView *scrollView = (UIScrollView *)subview;
            CGRect frame = scrollView.frame;
            CGPoint contentOffset = scrollView.contentOffset;

            // Translate the logical offset into the sub-scrollview's real content offset and frame size.
            // Methodology:

            // (1) As long as the sub-scrollview has not yet reached the top of the screen, set its scroll position
            // to 0.0 and position it just like a normal view. Its content scrolls naturally as the container
            // scroll view scrolls.
            if (self.contentOffset.y < yOffsetOfCurrentSubview) {
                contentOffset.y = 0.0;
                frame.origin.y = yOffsetOfCurrentSubview;
            }
            // (2) If the user has scrolled far enough down so that the sub-scrollview reaches the top of the
            // screen, position its frame at 0.0 and start adjusting the sub-scrollview's content offset to
            // scroll its content.
            else {
                contentOffset.y = self.contentOffset.y - yOffsetOfCurrentSubview;
                frame.origin.y = self.contentOffset.y;
            }

            // (3) The sub-scrollview's frame should never extend beyond the bottom of the screen, even if its
            // content height is potentially much greater. When the user has scrolled so far that the remaining
            // content height is smaller than the height of the screen, adjust the frame height accordingly.
            CGFloat remainingBoundsHeight = fmax(CGRectGetMaxY(self.bounds) - CGRectGetMinY(frame), 0.0);
            CGFloat remainingContentHeight = fmax(scrollView.contentSize.height - contentOffset.y, 0.0);
            frame.size.height = fmin(remainingBoundsHeight, remainingContentHeight);
            frame.size.width = self.contentView.bounds.size.width;
            
            scrollView.frame = frame;
            scrollView.contentOffset = contentOffset;

            yOffsetOfCurrentSubview += scrollView.contentSize.height;
        }
        else {
            // Normal views are simply positioned at the current offset
            CGRect frame = subview.frame;
            frame.origin.y = yOffsetOfCurrentSubview;
            frame.size.width = self.contentView.bounds.size.width;
            subview.frame = frame;
            
            yOffsetOfCurrentSubview += frame.size.height;
        }
    }
    
    self.contentSize = CGSizeMake(self.bounds.size.width, fmax(yOffsetOfCurrentSubview, self.bounds.size.height));
}

@end

Остальной код вполне шаблонный, ознакомиться с ним можно на GitHub.

Auto Layout

Пара слов о Auto Layout: OLEContainerScrollView не использует эту функцию внутри, и наверно невозможно реализовать подобное поведение с помощью распорок Auto Layout (UIScrollView и Auto Layout не совсем лучшие друзья, в любом случае). Тем не менее, не должно быть проблем с использованием этого класса с другими объектами, которые используют Auto Layout для разметки внутри себя. Как я говорил ранее, вы можете вполне свободно смешивать ручную разметку и auto layout.

Эй, а где Podspec?

Я намеренно не cделал (пока) CocoaPod из OLEContainerScrollView. Я написал этот класс, чтобы решить свою очень специфическую проблему, и я верю, что у него есть достаточный потенциал, чтобы вырасти в общий компонент. Безусловно, пока что он таковым не является. Ограничения следующие:

  • Класс поддерживает только вертикальную разметку вложенных видов и только вертикальную прокрутку.
  • Вертикальная разметка очень негибкая. При ней все вложенные виды растягиваются до ширины контейнера. Она не поддерживает промежутки между вложенными видами или их свободное размещение.
  • Размер контента изменяется вслед за размерами вложенных видов, но не анимируется достаточно хорошо.

Если вы заинтересованы в использовании данного класса, я буду рад, если вы загляните в его код и используете его в своих целях. Так же я буду рад добавить ваши улучшения (делайте pull request’ы). И напишите мне, если хотите видеть этот класс в виде CocoaPod’а.

Заключение

Размещение нескольких (в том числе scroll) видов на одном общем контейнере-scroll view не является приёмом на каждый день, но этот подход может упростить вам работу с источниками данных для таблиц и коллекций, а так же упростить разметку и компоновку, превратив их секции в отдельные виды.

OLEContainerScrollView в данный момент не является полнофункциональным компонентом, но я надеюсь, что он станет таким с вашей помощью. Во всяком случае, написание этого компонента помогло мне углубить моё понимание устройства UIScrollView и координатной системы UIKit.

Сноски:

  1. Это то, что я бы хотел сделать более гибким в будущей версии. В данный момент класс поддерживает только вертикальную разметку.
  2. Я сделал это пока только для видов, наследующих от UIScrollView. Высота frame обычного UIView остаётся неизменной, даже если вид не находится в области видимости. Я сделал так, чтобы избежать изменений в разметке (или даже ошибок в auto layout), которые могли бы быть вызваны обнулением размеров вида, который не ожидает таких манипуляций со своими размерами. Изменение такого поведения будет несложным в дальнейшем.
От переводчика

Так же желательно для более глубокого понимания прочитать статью «Understanding UIScrollView».

Автор: egormerkushev

Источник

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


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