Connect with us

Разработка

Как обнаружить обрезку Text в SwiftUI?

SwiftUI имеет низкий порог вхождения, но использовать его правильно непросто. Именно в этом и заключается прелесть технического обучения: каждый, казалось бы, независимый аспект знаний может в какой-то момент стать ключом к решению сложных задач.

Опубликовано

/

     
     

Text активно используется в SwiftUI. По сравнению с аналогами в UIKit/AppKit, Text не требует настройки и работает «из коробки», но это также означает, что разработчики теряют контроль над ним. В этой статье я продемонстрирую на примере из реальной жизни, как решить, казалось бы, «невозможные» задачи с помощью подхода SwiftUI: найти и использовать первое представление в заданном наборе, где текст не обрезан.

Интересная задача

Несколько дней назад я получил письмо от Марка. Разрабатывая iOS-клиент для GNU Taler, он столкнулся с интересной проблемой адаптации макета.

Ему нужно было отобразить в списке представления, содержащие описания и суммы. Чтобы обеспечить полное отображение как описания, так и сумм, Марк задал несколько различных схем макета:

  • Компактный макет: однострочное описание + горизонтальное расположение
  • Стандартный макет: описание до двух строк + горизонтальное расположение
  • Расширенный макет: неограниченное количество строк + вертикальное расположение

Марк хотел автоматически выбирать первую схему макета, которая могла бы отображать текст полностью (без обрезки) в соответствии с приоритетом (Компактный → Стандартный → Расширенный).

struct ContentView1: View {
    // MARK: - Sample Data
    private let short = "short Text"
    private let medium =
        "some text which can obviously be wrapped in two lines"
    private let long = "A lot of text which can obviously be wrapped in many lines!"

    var body: some View {
        List {
            AdaptiveAmountRow(title: short)
            AdaptiveAmountRow(title: medium)
            AdaptiveAmountRow(title: long)
        }
    }
}

// Three preset layout schemes

/// Compact layout - Single-line horizontal arrangement
HStack(alignment: .center) { 
    titleView // maxLines = 1
    Spacer(minLength: 2)
    amountView.fixedSize() // Amount never truncates
}

/// Standard layout - Two-line horizontal arrangement
HStack(alignment: .lastTextBaseline) {
    titleView // maxLines = 2
    Spacer(minLength: 2)
    amountView.fixedSize()
}

/// Extended layout - Vertical arrangement
VStack(alignment: .leading) {
    titleView // maxLines = nil (unlimited)
    HStack {
        Spacer()
        amountView.fixedSize()
    }
}

В этой схеме AdaptiveAmountRow интеллектуально выбирает один из трёх предустановленных макетов в зависимости от длины текстового содержимого:

  1. Если текст может быть полностью отображен в компактном макете, отдайте предпочтение однострочному макету.
  2. Если требуется перенос строк, попробуйте стандартный макет с двухстрочным макетом.
  3. Если текст слишком длинный, выберите расширенный макет, обеспечивающий полное отображение всего содержимого.

Как обнаружить обрезку Text в SwiftUI?

Я упростил задачу Марка в приведенном выше описании, но сохранил ключевую информацию.

Где же здесь трудности?

Перед нами две основные проблемы:

  1. Как определить, обрезан ли текст — Text в SwiftUI не предоставляет никакой информации о том, отображается ли текст полностью. При ограниченном пространстве он может только использовать доступное пространство и интеллектуально обрезать содержимое в соответствии с правилами обрезания.
  2. Как интеллектуально выбирать и применять макеты — Нужно найти первый необрезанный вид и использовать его размер в качестве требуемого размера контейнера.

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

Клин клином — использование SwiftUI для определения обрезания текста

Возможно, многие разработчики первым делом вспомнят о NSAttributedString, пытаясь определить, отображается ли текст полностью. Благодаря таким методам, как boundingRect, NSAttributedString может рассчитывать требуемый размер для размещения текста при заданных ограничениях (например, ограниченная ширина или высота). Однако этот подход не совсем подходит для требований Марка. Поскольку он требует точного получения информации о шрифте SwiftUI Text (включая динамическую корректировку шрифтов), а также учитывая тонкие различия в том, как SwiftUI обрабатывает рендеринг и усечение текста, применение этого метода не только громоздко, но и подвержено ошибкам.

Хотя Text в SwiftUI не позволяет определить, отображается ли он полностью, разработчикам несложно потребовать от Text игнорировать предлагаемый размер в другом измерении, когда одно измерение ограничено, обеспечивая полное отображение. Используя fixedSize + GeometryReader, мы можем получить информацию о размере, необходимую в другом измерении для полного отображения текущего текста, когда размер одного измерения фиксирован.

struct Dimension: View {
    private let long = "A lot of text which can obviously be wrapped in many lines!"
    var body: some View {
        Text(long)
            .border(Color.red, width: 2)
            .fixedSize(horizontal: false, vertical: true)
            .background(
                GeometryReader { geometry in
                    Color.clear
                        .task(id: geometry.size) {
                            print(geometry.size) // 100.0 x 108.3
                        }
                })
            .frame(width: 100, height: 50)
            .border(.blue, width: 2)
    }
}

Как обнаружить обрезку Text в SwiftUI?

В приведённом выше коде, хотя мы ограничиваем доступное пространство для текста по горизонтали и вертикали с помощью .frame(width: 100, height: 50), .fixedSize(horizontal: false, vertical: true) заставляет текст игнорировать ограничения по вертикали и обеспечивать полное отображение текста. С помощью GeometryReader мы видим, что при ограничении ширины 100 для полного отображения всего текста требуется вертикальный размер 108,3.

Аналогичным образом, мы можем использовать тот же принцип для определения минимальной ширины, необходимой для полного отображения содержимого при ограничении по высоте.

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

  • Размер, который Text фактически занимает после отображения при заданном предлагаемом размере
  • Вертикальный размер, необходимый для полного отображения текста, исходя из текущей предлагаемой ширины
  • Горизонтальный размер, необходимый для полного отображения текста, исходя из текущей предлагаемой высоты

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

struct Dimension: View {
    private let long = "A lot of text which can obviously be wrapped in many lines!"
    @State private var displayDimension: CGSize?
    @State private var verticalDimension: CGSize?
    @State private var horizontalDimension: CGSize?
		
    var isTruncated: Bool? {
        guard let displayDimension, let verticalDimension, let horizontalDimension else { return nil }
        // Check if truncated in any dimension
        if displayDimension.width > verticalDimension.width || displayDimension.height > horizontalDimension.height {
            return true
        } else {
            return false
        }
    }
    
    var body: some View {
        Text(long)
            .getDimension(dimension: $displayDimension)
            .background(
                Text(long) // Minimum height needed for full display under width constraint
                    .fixedSize(horizontal: false, vertical: true)
                    .hidden()
                    .getDimension(dimension: $verticalDimension)
             )
            .background(
                Text(long) // Minimum width needed for full display under height constraint
                    .fixedSize(horizontal: true, vertical: false)
                    .hidden()
                    .getDimension(dimension: $horizontalDimension)
                )
            .frame(width: 100, height: 50)
        
        if let isTruncated  {
            Text(isTruncated ? "Truncated" : "Not Truncated")
        }
    }
}

Как обнаружить обрезку Text в SwiftUI?

После сокращения текстового содержимого мы видим, что isTruncated показывает false.

Как обнаружить обрезку Text в SwiftUI?

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

Теперь нам нужно выбрать подходящий метод уведомления родительского представления о результате isTruncated. В данном решении я выбрал PreferenceKey.

Альтернативный ViewThatFits

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

Например, в трёх предустановленных макетах, поскольку Text автоматически обрезает текст, ViewThatFits не может автоматически делать выбор на основании этого. Очевидно, нам нужно создать интеллектуальный селектор макетов с собственным правилом.

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

Использовать протокол Layout? Это не невозможно, но, учитывая ограничения протокола при получении статуса усечения текста, я выбрал решение ZStack + layoutPriorityиз недавней статьи.

В статье «Изучение секретов layoutPriority в SwiftUI ZStack» мы рассмотрели интересное правило, касающееся макетов ZStack: ZStack не просто берёт минимальные границы, содержащие все subview. Он учитывает только набор представлений с наивысшим значением layoutPriority, вычисляет минимальный размер выравнивания, который может вместить их все, и использует его в качестве своего собственного требуемого размера.

Это означает, что если мы поместим все заданные макеты в один ZStack, проверим их по одному в соответствии с приоритетом, найдём первый макет, где текст отображается полностью, установим его layoutPriority выше, чем у других, и скроем отображение других макетов. В этом случае ZStack будет использовать требуемый размер этого предустановленного макета в качестве своего собственного требуемого размера. Таким образом, мы получаем собственный интеллектуальный селектор макетов: находим первый макет, соответствующий требованиям, и используем его требуемый размер в качестве окончательного требуемого размера.

Общая логика реализации такова:

truct ZStackContainer: View {
    @State private var layoutStatuses: [LayoutMode: Bool] = [:]

    /// The first layout mode that can display the content without truncation
    private var optimalLayoutMode: LayoutMode? {
        return layoutStatuses.keys
            .sorted(by: { $0 < $1 })
            .first { !(layoutStatuses[$0] ?? true) }
    }

    private func isLayoutSelected(_ mode: LayoutMode) -> Bool {
        return optimalLayoutMode == mode
    }
    
    var body: some View {
        ZStack {
            Compact()
                .layoutPriority(isLayoutSelected(.compact) ? 2 : 1)
                .opacity(isLayoutSelected(.compact) ? 1 : 0)

            Standard()
                .layoutPriority(isLayoutSelected(.standard) ? 2 : 1)
                .opacity(isLayoutSelected(.standard) ? 1 : 0)

            Extended()
                .layoutPriority(isLayoutSelected(.extended) ? 2 : 1)
                .opacity(isLayoutSelected(.extended) ? 1 : 0)
        }
    }
}

На этом этапе мы решили эту задачу, используя исключительно SwiftUI-подход, и достигли конечной цели.

Открытый исходный код и Taler

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

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

С разрешения Марка я организовал описанный выше подход в виде готового кода и разместил его на GitHub для всеобщего ознакомления.

На изображении ниже показано практическое применение этого интеллектуального решения в кошельке Taler:

Как обнаружить обрезку Text в SwiftUI?

На трех снимках экрана слева направо наглядно показано, как система автоматически выбирает наиболее подходящий макет на основе разной длины текста описания транзакции, обеспечивая полное отображение всей информации и сохраняя при этом понятный и элегантный интерфейс.

Интеграция ведёт к мастерству

За последние несколько лет, по мере углубления моего понимания SwiftUI, я всё больше убеждаюсь, что при использовании SwiftUI для вёрстки глубокое понимание его базовых механизмов может помочь вам эффективнее справляться со всё более сложными требованиями к вёрстке.

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

  • Гибкое использование fixedSize
  • Измерение размера с помощью GeometryReader
  • background не влияет на требуемый размер композитного представления
  • Передача данных вверх с помощью PrefereceKey
  • Механизм layoutPriority в ZStack

Но только полностью освоив и интегрировав эти механизмы, вы сможете решить некоторые, казалось бы, «невыполнимые» задачи.

SwiftUI имеет низкий порог вхождения, но использовать его правильно непросто. Именно в этом и заключается прелесть технического обучения: каждый, казалось бы, независимый аспект знаний может в какой-то момент стать ключом к решению сложных задач.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: