Адаптированный перевод

Согласно правилам Хабра я указал ссылку на оригинальную статью. Но хочу отметить, что данный перевод является адаптацией оригинальной статьи с авторскими изменениями.

Давайте попробуем посмотреть за короткий промежуток времени как работает масштабирование в Image. Для, тем, кто пришел в SwiftUI из UIKit думает что система достаточно простая и все модификаторы понятны, но иногда это работает, не так, как вы ожидаете.

Растяжение изображений

Началось это все после pull request коллеги с фиксом бага деформации изображения. Во время ревью кода, я был удивлен, что предыдущая версия кода уже использовала aspectRatio и новая версия была странным решением, объединяющим ZStack и прочие хаки.

Image("otus")
          .resizable()
          .aspectRatio(2, contentMode: .fill)

Проблема в том, что используя aspect ratio в Image и установка ratio — просто некрасиво растягивает изображение и делает использование самого ratio параметра бесполезным.

Забавно, что все поиски, которые дают решение этого самого простого кейса — не объясняют почему так происходит. Примеры решений всегда не содержат .aspectRatio(contentMode: .fill) и используют другие комбинации кода для фикса.

Что бы мне хотелось (пользователи UIKit поймут), чтобы существовало поведение аналогичное UIImageView content mode — то есть масштабировало картинку, а не просто растягивало ее. То есть — я хочу использовать aspectRatio без указания конкретного числового варианта и получить верное масштабирование.

Собственно это и должно делать aspectRatio, подумал я.

Должна существовать комбинация модификаторов, которая без хаков вроде ZStack и прочего дает прелполагемый результат — думал я, но похоже ошибся.

AspectRatio и layout

Как и в случае со многими модификаторами в SwiftUI, важно понимать, что они на самом деле не применяют никакого поведения напрямую. Это не похоже на то, что мы говорим «объекту» изображения соблюдать соотношение сторон. Неа! Здесь мы не устанавливаем свойства объекта. Так что не позволяйте своему уму все усложнять и заставлять вас думать так. Это лишь, по сути, желаемое видение результата, а не реальные свойства объекта.

Модификатор aspectRatio — это модификатор layout. Это означает, что он принимает участие в процессе компоновки SwiftUI. На самом деле процесс компоновки довольно прост: предложенные размеры идут вниз по иерархии, а уже принятые поднимаются вверх, выполняя позиционирование с ними.

Вооруженный тулзой для анализа SwiftUI компонентов от ребят из Swift Talk я продебажил поведение модификатора для того, чтобы сложить картинку в голове. Это то, чем я часто занимаюсь, даже если я думаю, что знаю, как все должно работать, я хочу убедиться, что моя модель верна. И я копаю все глубже и глубже, пока не смогу  придумать больше вопросов, которые могли бы пошатнуть мою ментальную модель.

Начнем с проблемного примера:

Image("otus")
            .resizable()
            .logSizes("Image")
            .aspectRatio(2, contentMode: .fit)
            .logSizes("Ratio")

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

Давайте попробуем без числового значения вообще, просто применим .fill

Image("otus")
            .resizable()
            .logSizes("Image")
            .aspectRatio(contentMode: .fill)
            .logSizes("Ratio")

Тут изобржение выглдяит вполне корректно, без чрезмерного растягивания (результат зависит от конкретного изображения — можете поэксперементировать — но в целом он приемлим).

Однако давайт посмотрим на лог — это не Image магическим путем (внутри, как UIImageView) не растягивается, оно принимает нужный размер, в котором растягиваться не нужно. Модификатор aspect ratio на этот раз выполняет некоторые вычисления, чтобы найти правильный размер, соответствующий алгоритму. И опять же, просто рендерит себя в этом размере, больше ничего.

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

Это объясняет, почему так много примеров предоставляют frame, а не ratio, что противоречит цели динамических пользовательских интерфейсов. Таким образом, решение, предложенное моим коллегой, было правильным: использовать соотношение сторон для вычисления «frame», в который нужно встроить изображение, а затем позволить Image заполнить все пространство.

Несмотря на это, я подумал: «Должен же быть способ сделать это, соединив модификаторы в цепочку!»…

Связывание соотношений сторон во имя справедливости

Итак, идея заключалась в том, чтобы сохранить aspect ratio изображения без определенного числа и использовать другой родительский модификатор для создания правильного размера:

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

(возьмем другое изображение, более отлчиное от квадрата для яркого примера)

Как видите — не сработало.

Если вы проанализируете логи, вы увидите, как внешний модификатор изменяет размер на квадрат, но внутреннее соотношение затем вычисляет новый размер в соответствии с его правилом заполнения (fill) и используется для вычисления размера изображения. В конце результат тот же, поскольку изображение получает этот «заполненный» кадр.

Теперь вы можете сказать: «Но я указал соотношение сторон 1, почему это не соблюдается?» и ответ снова тот же. Модификатор не делает ничего, кроме уменьшения предлагаемого размера. Представление нижнего члена иерархии может игнорировать это и выбрать другой размер, о котором сообщается выше. А когда система компоновки идет вверх, коррекции нет. Модификатор aspect ratio не заставит сообщаемый размер дочернего элемента быть равным 1.

Вы легко это увидите, если вместо этого воспользуетесь frame:

Image("otus2")
            .resizable()
            .logSizes("Image")
        // .aspectRatio(contentMode: .fill)
            .frame(width: 100, height: 300)
            .logSizes("Inner Ratio")
            .aspectRatio(1, contentMode: .fit)
            .logSizes("Outer Ratio")

Наглядно видно, как frame выбирает размер, игнорируя предлагаемый размер родительского соотношения сторон, и aspect ratio ничего не может с этим поделать. Опять же, предлагаемые размеры уменьшаются, а выбранные окончательные размеры увеличиваются. Ничего более. Неужели так просто.

Простые общие сценарии

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

Обычно примеры начинаются с демонстрации того, как изображения растягиваются:

Здесь мы видим, как frame предлагает размер изображения, а Image просто берет его и рендерит сам себя, что приводит к очевидному растяжению.

Тогда предлагаемое решение обычно состоит в том, чтобы просто использовать соотношение сторон без числа, чтобы исправить его:

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

Но, конечно, вы фиксируете размеры, что полностью противоречит любому современному, адаптируемому дизайну.

И просто для полноты картинки попробуйте вместо этого использовать fill:

Это еще больше все испортит, потому что изображение выходит за границы, вам нужно его обрезать. Попытайтесь разобраться, почему это происходит. Соотношение сторон по‑прежнему проверяет изображение на предмет желаемого соотношения и использует его для расчета правильного размера с помощью алгоритма заполнения, который увеличивает размер. Этот отчет поднимается до тех пор, пока не найдет кадр. Фрейм ничего не может с этим поделать, опять же, процесс компоновки, идущий вверх, сводится только к позиционированию, но он упрям и возвращает размер исправления, который вы ему дали. Это означает, что макет снаружи думает, что это 200, но изображение, отображается намного больше по итогу.

Виноват рендеринг изображения

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

Проблема здесь в том, что рендеринг изображения довольно простой. Как только вы применяете resizable, это просто означает, что изображение будет отображать все пиксели, заполняя весь заданный ему размер. Это имеет смысл, но раздражает, учитывая обстоятельства. У этого модификатора есть resizingMode, который заставит вас надеяться, что это решение, но нет, он просто переключается между растяжением и мозаикой. Я думаю, что если бы вы могли сказать, что этот режим вписывается (fit) или заполняется (fill), это больше соответствовало бы моим ожиданиям.

Решение первое (не самое оптимальное)

Итак, каково решение? К сожалению, нам нужно разбить иерархию на две части.

Сначала создайте frame, ограничив размер до определенного желаемого соотношения.

Rectangle()
  .aspectRatio(2, contentMode: .fit)

Затем вставьте изображение в этот frame и используйте другое соотношение сторон, чтобы придать ему нужный размер. Нам также нужно обрезать результат, так как использование заливки приведет к тому, что Image выйдет за границы, как описано выше.

Rectangle()
          .aspectRatio(2, contentMode: .fit)
          .overlay {
              Image("otus")
                  .resizable()
                  .aspectRatio(contentMode: .fill)
          }
          .clipped()

Поскольку это немного громоздко, я подумал, что было бы неплохо иметь для этого многоразовый модификатор. Но это связано с большим количеством вопросов, связанных с дизайном API, поэтому не стесняйтесь делать то, что больше подходит для вашей кодовой базы. Вот как пример моего решения:

extension View {
    func framedAspectRatio(
        _ ratio: CGFloat? = nil,
        contentMode: ContentMode
    ) -> some View {
        Color.clear
            .aspectRatio(ratio, contentMode: .fit)
            .overlay {
                self
                    .aspectRatio(contentMode: contentMode)
            }
            .clipped()
    }
}

С таким расширением итоговый код в принципе не плох:

Image("otus")
            .resizable()
            .framedAspectRatio(2, contentMode: .fill)

Лучшее решение с frame

На самом деле взгляд в направление - разбить решения на 2 куска был верный изначально, однако разобравшись в работе frame - можно найти лучшее решение:

В документации frame примерно следующее:

Если в заданном измерении не указано ни минимальное, ни максимальное ограничение, фрейм принимает поведение размера своего дочернего элемента в этом измерении. Если оба ограничения указаны в frame, frame безоговорочно принимает предложенный для нее размер, привязанный к ограничениям.

Ну это же то, что я хотел!

Image("otus")
            .resizable()
            .aspectRatio(contentMode: .fill)
        // this is the magic trick
            .frame(
                minWidth: 0,
                maxWidth: .infinity,
                minHeight: 0,
                maxHeight: .infinity
            )
            .aspectRatio(2, contentMode: .fit)
            .clipped()

Но я должен признать, что потратил несколько часов, пытаясь полностью понять поведение frame. Когда я был счастлив, понимая, что делает передача обоих ограничений, но я пришел в замешательство, потому что, когда я передал только minHeight: 0, поведение все еще было тем, которое я хотел!

Диаграмма с SwiftUI Lab была очень полезна для понимания документации в наглядной форме, но даже при этом изменение поведения от следования дочернему размеру к предлагаемому размеру немного сбивает с толку. Я понимаю общие случаи, и он всегда делает то, что вы от него ожидаете, но... Мне приходится слишком много думать, меня все еще что-то смущает.

Заключение

Я люблю эти головоломки! Мне нравится углубляться не для того, чтобы найти решение, а для того, чтобы убедиться, что я понимаю, что происходит. В конце пути я буду счастлив укрепить свою мысленную модель того, как все работает, даже если окончательный ответ не очень удовлетворителен.

В конечном счете, я надеюсь, что этот пост послужит не только объяснением такого поведения, но и вдохновит вас на то, чтобы углубиться и убедиться, что вы действительно понимаете, как все работает.

Источники:

Для красивого, функционального и эффективного UI не нужны Autolayout, Xib и сториборды. SwiftUI - современный фреймворк для быстрого создания приложений от Apple. Приглашаю вас на бесплатный урок, где покажем и расскажем, как с помощью данной технологии сделать простое приложение для просмотра галереи изображений.

Комментарии (0)