Вывод текста в iOS: CoreText, NSAttributedString

Я хотел бы Вам рассказать про вывод текста на экран в iOS. Вначале это задумывалось как туториал, затем как сбор неочевидных фактов, а получилось что получилось.

Способы вывода текста условно можно разделить на несколько категорий:

  • UIKit — привычные UI контролы. Дают только базовые возможности по отображению, совсем мало возможностей по форматированию
  • UIKit + NSAttributedString — привычные UI контролы с возможностью установки строки с атрибутами отображения. Куча возможностей по форматированию
  • CoreText — фреймворк для работы с текстом и текстовыми представлениями. В связке с NSAttributedString дает массу возможностей по форматированию текста и, благодаря нескольким уровням абстракций над элементами текста, расширенные методы по их отображению
  • CoreGraphics — с помощью него тоже можно отрисовывать текст


NSAttributedString


Перво-наперво, нужно рассказать о базовой вещи, которая называется NSAttributedString или CFAttributedStringRef если мы работаем с toll-free-bridge объектами. Это строка, которой можно задавать атрибуты отображения.

NSDictionary *textAttributes = @{(NSString *)kCTFontAttributeName : [UIFont systemFontOfSize:16]};
NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:LoremIpsum attributes:textAttributes];

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


Здесь нужно обозначить один важный момент, о котором мне любезно напомнил DemoniacDeath в комментариях: CoreText для отрисовки использует CTFont, который на самом деле toll-free-bridge к UIFont начиная только с iOS6. Поэтому приведение типов, которое используется в примерах для краткости, будет работать только на iOS6, а на всем что ниже — нужно создавать CTFont на основе UIFont:

CTFont ctFont = (__bridge_transfer id)CTFontCreateWithName(uiFont.fontName, uiFont.pointSize, NULL);

Итак, доступных стандартных атрибутов довольно много:

  • kCTForegroundColorAttributeName — цвет шрифта
  • kCTUnderlineStyleAttributeName — стиль подчеркивания текста: CTUnderlineStyle вляет на тип подчеркивания: одиночная линия, толстая одиночная, двойная; CTUnderlineStyleModifiers влияет на тип линии: прерывистая, точками, etc
  • kCTUnderlineColorAttributeName — цвет подчеркивания
  • kCTForegroundColorFromContextAttributeName — нужно ли брать цвет из графического контекста. Если YES, тогда параметры kCTUnderlineColorAttributeName и kCTUnderlineStyleAttributeName не будут влияет на цвет, а будет использован цвет контекста CGContextSetFillColorWithColor
  • kCTLigatureAttributeName — режим использования лигатур. Режимы:
    1. 0 определяет, что будут использованы только те лигатуры, которые необходимы для нормального отображения символов
    2. 1 определяет, что будут использованы только стандартные лигатуры. Какие лигатуры будут являться стандартными, зависит от шрифта и языка. Например, для английского это fl и fi. Для иврита, арабского, тайского – свои интересные символы
    3. 2 определяет, что по возможности максимум символов должно быть сгруппировано в лигатуры
  • kCTParagraphStyleAttributeName – в тексте могут быть параграфы. Этот параметр определяет стиль параграфа. Все параметры можно посмотреть в определении CTParagraphStyleSpecifier. Я бы хотел отдельно обратить внимание на kCTParagraphStyleSpecifierBaseWritingDirection – определяет направление отображения текста (слева направо / справа налево) для текущего параграфа. По умолчанию используется kCTWritingDirectionNatural – направление отображения будет выбрано согласно этому документу. Но можно направление ввода задавть вручную, устанавливая kCTWritingDirectionLeftToRight / kCTWritingDirectionRightToLeft
  • kCTRunDelegateAttributeName — очень важный и интересный параметр. На вход он принимает набор функций, которые возвращают ширину и еще несколько параметров, на основе которых расчитывается высота строки. Затем я расскажу об этом параметре подробнее и его применении

Например, текст с кернингом 3.0, размером шрифта 14 и подчеркиванием, отступом всего текста справа на 20.0 пунктов, красной строкой на 40.0 пунктов и расстоянием между параграфами в 30.0 пунктов:

CTParagraphStyleSetting paragraphSettings[] = (CTParagraphStyleSetting[]){
    (CTParagraphStyleSetting){ kCTParagraphStyleSpecifierFirstLineHeadIndent, sizeof(float_t), (float_t[]){ 40.0f } },
    (CTParagraphStyleSetting){ kCTParagraphStyleSpecifierHeadIndent, sizeof(float_t), (float_t[]){ 20.0f } },
    (CTParagraphStyleSetting){ kCTParagraphStyleSpecifierParagraphSpacing, sizeof(float_t), (float_t[]){ 30.0f } }
};
id paragraphStyle = (__bridge_transfer id)CTParagraphStyleCreate(paragraphSettings, sizeof(paragraphSettings) / sizeof(paragraphSettings[0]));
	
NSDictionary *textAttributes = @{
    (NSString *)kCTFontAttributeName : [UIFont systemFontOfSize:14],
    (NSString *)kCTKernAttributeName : @(3.0),
    (NSString *)kCTUnderlineStyleAttributeName : @(kCTUnderlineStyleSingle),
    (NSString *)kCTParagraphStyleAttributeName : paragraphStyle
};



Drawing


UIKit


Теперь о том, как же это все можно отрисовывать. Начиная с iOS6 в UIKit у NSAttributedString появилась категория NSStringDrawing и можно просто сделать так:

NSDictionary *textAttributes = @{(NSString *)kCTFontAttributeName : [UIFont systemFontOfSize:16]};
NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:LoremIpsum attributes:textAttributes];
[attributedString drawInRect:rect];

Есть еще один метод:

- (void)drawWithRect:(CGRect)rect options:(NSStringDrawingOptions)options context:(NSStringDrawingContext *)context;

который прикидывается якобы настраиваемым при помощи передаваемых параметров и специального контекста. Откровенно говоря, я никогда не использовал эти методы, потому что минимальная версия iOS, которую мы поддерживаем в нашем продукте — это 4.3.
Так же можно передать созданный NSAttributedString в UILabel, UITextField или UITextView через метод setAttributedString:. Этот метод тоже поддерживается только с iOS6.0. Кстати, для этих элементов, начиная с iOS6.0, атрибуты для отображения можно задавать прямо в InterfaceBuilder в xib или storyboard.

CoreText


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



Первый уровень. CTFramesetter

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

NSDictionary *textAttributes = @{(NSString *)kCTFontAttributeName : [UIFont systemFontOfSize:16]};
NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:LoremIpsum attributes:textAttributes];
	
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef)(attributedString));
CGSize suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRangeMake(0, 0), NULL, CGSizeMake(CGRectGetWidth(rect), CGFLOAT_MAX), NULL);

Здесь зафиксирована ширина, в которую нужно вписать текст. suggestedSize будет содержать искомую высоту текста. То же можно сделать и с шириной.

Второй уровень. CTFrame

Этот объект создает описанным выше CTFramesetter и он уже полностью готов к отрисовке.

CGPathRef path = CGPathCreateWithRect((CGRect){CGPointZero, suggestedSize}, NULL);
CTFrameRef textFrame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, 0), path, NULL);
CTFrameDraw(textFrame, context);

Хочу подробнее остановиться на параметрах следующего метода:

CTFrameRef CTFramesetterCreateFrame(
      	CTFramesetterRef framesetter,
      	CFRange stringRange,
      	CGPathRef path,
      	CFDictionaryRef frameAttributes )

  • framesetter — собственно объект, содержащий строку с атрибутами, по которому нужно построить графическое отображение
  • stringRange — участок строки, для которого будет строиться отображение. Длина равная 0 обозначает, что нужно взять строку до конца, начиная с текущей позиции. То есть CFRange(0,0) обозначает строку от начала до конца
  • path — задает набор геометрических примитивов, в которые должен быть вписан текст
  • frameAttributes — дополнительные параметры по вписыванию. Например, kCTFramePathFillRuleAttributeName задает правило как будет себе вести текст в пересекаемых областях; kCTFrameProgressionAttributeName определяет порядок создания линий: снизу вверх (горизонтальный текст) или справа налево (вертикальный текст)

Парочка примеров.

CGMutablePathRef path = CGPathCreateMutable();
	
CGPathAddEllipseInRect(path, NULL, (CGRect){CGPointZero, CGSizeMake(suggestedSize.width, suggestedSize.height / 2)});
CGPathAddEllipseInRect(path, NULL, (CGRect){0, suggestedSize.height / 2, CGSizeMake(suggestedSize.width, suggestedSize.height / 2)});
 



CGAffineTransform transformation = CGAffineTransformMakeRotation(M_PI_4);
CGPathAddEllipseInRect(path, &transformation, CGRectMake(20, -200, 400, 130));
CGPathAddRect(path, &transformation, CGRectMake(50, -150, 200, 200));



В последнем примере также используется параметр kCTFramePathFillWindingNumber для CTFrame, поэтому пересекаемые области тоже заполнены текстом.

Еще два важных момента:
  1. Если Вы попробуете один из выше приведенных примеров, то результат будет сильно отличаться от приведенного мной, а именно: изображение будет перевернутым, поскольку CoreText формирует изображение начиная с нижнего левого угла, как и в любом создаваемом CGContext, например, CGBitmapContext. Поэтому для правильного вывода нужно преобразовать текущий графический контекст
  2. Можно и нужно задавать трансформацию только для текста, а не всего графического контекста. Это делается через CGContextSetTextMatrix. Особенность этой штуки в том, что CGSave/RestoreContext не сохраняет и не восстанавливает матрицу трансформации для текста. Поэтому ее нужно устанавливать всегда перед отрисовкой, иначе в ней могут оставаться значения, которые были выставлены в любом другом вызове этого метода в недрах системных библиотек или просто в других частях Вашей программы

CGContextSetTextMatrix(context, CGAffineTransformIdentity);
CGContextScaleCTM(context, 1.0f, -1.0f);
CGContextTranslateCTM(context, 0.0f, -suggestedSize.height);

Вот что будет с не единичной матрицой трансформации:

CGContextSetTextMatrix(context, CGAffineTransformMakeRotation(M_PI_4));
CGContextScaleCTM(context, 1.0f, -1.0f);
CGContextTranslateCTM(context, 0.0f, -suggestedSize.height);



Перед дальнейшим расказом я бы хотел украсть еще одну картинку с сайта эппл:



Лирическое отступление. CTTypesetter

Именно эта сущность участвует в создании CTFrame. Ее роль — создание тех самых текстовых графических примитивов, которые уже готовы к отрисовке на данном этапе. Он осуществляет перенос строк по заданному алгоритму (по словам, символам, etc), обрезает все что не влезло.

Уровень третий. CTLine

Это то, из чего состоит CTFrame. CTLine можно отрисовывать, задавая позиции по котрым они и будут расположены: беря расчитанные из CTFrame при помощи метода CTFrameGetLineOrigins или же задавая по какому-то Очень Хитрому Алгоритму.

CFArrayRef lines = CTFrameGetLines(textFrame);
for (CFIndex i = 0, linesCount = CFArrayGetCount(lines); i < linesCount; ++i) {
    	CGPoint lineOrigin = CGPointZero;
    	CTFrameGetLineOrigins(textFrame, CFRangeMake(i, 1), &lineOrigin);
    	CGContextSetTextPosition(context, lineOrigin.x, lineOrigin.y);
    	CTLineDraw(CFArrayGetValueAtIndex(lines, i), context);
}

Что еще обязательно стоит знать. У линии есть несколько типографских параметров: baseline, ascent, descent, leading. Про каждый можно почитать здесь. CTFrameGetLineOrigins возвращает именно baseline. Остальные параметры можно узнать через метод:

doubCTLineGetTypographicBounds(CTLineRef line, CGFloat* ascent, CGFloat* descent, CGFloat* leading);


Красный цвет — descent, черный — baseline, синий — ascent.

К этим параметрам мы еще вернемся. А пока хочу заметить, что descent и ascent будут браться для всей строки как минимальный и максимальный для всех шрифтов, которые использованы в строке. Например, для приведенного выше текста, хорошо заметно, что в строке, где присутствует иврит, descent больше.

Еще несколько полезных методов:

CFRange CTLineGetStringRange(CTLineRef line)

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

CFIndex CTLineGetStringIndexForPosition(CTLineRef line, CGPoint position)

Возвращает строковый индекс для указанной позиции в строке. Например, может использоваться для определения нажатия на определенные области текста. Обратите внимание, что контекст перевернутый, и (0,0) будет находиться в самой нижней строке.

CTLineRef CTLineCreateWithAttributedString(CFAttributedStringRef string)

Для отрисовки линии не обязательно создавать всю цепочку из CTFramesetter->CTFrame->CTLine. Можно создать сразу и линию. Довольно часто возникает задача также ее обрезать, если текст не вмещается в заданную область:

CTLineRef CTLineCreateTruncatedLine(CTLineRef line, double width, CTLineTruncationType truncationType, CTLineRef truncationToken)

Четвертый уровень. CTrun

Наверное, самая полезная абстракция для того, кто пишет свой элемент для ввода текста. CTRun — это то, во что группируются символы с одинаковыми стилями для отрисовки группами. Вернее группируются начиная с iOS6.0, а во всем что младше — каждый символ будет представлять собой отдельный CTRun. С самого начала, нужно уяснить один легкий и очевидный момент: есть символы, а есть глифы. Из этого факта следуют еще два момента, тоже легких, но уже неочевидных: один символ может состоять из одного глифа, а может и из нескольких; один глиф может представлять сразу несколько символов. Например, большинство смайликов представляют собой один глиф, но состоят из нескольких UTF символов.
Всем тем, кто собирается работать с CTRun я строго рекомендую ознакомиться с CTRun.h – все методы которые там есть исключительно полезны.

CFIndex CTRunGetGlyphCount(CTRunRef run)

Возвращает количество глифов в CTRun.

CFRange CTRunGetStringRange(CTRunRef run)

Возвращает диапазон от всей строки в котором расположен CTRun. Длина этого диапазона не всегда равна результату предыдущего метода!

const CFIndex* CTRunGetStringIndicesPtr(CTRunRef run)
void CTRunGetStringIndices(CTRunRef run, CFRange range, CFIndex buffer[])

Этот метод связан с предыдущим. Он возвращает позиции глифов в строке. Например, есть текст из нескольких смайликов:


Начиная с iOS6.0 CTrun будет следующим:
CTRun: string range = (0, 6), string = "\U0001f437\U0001f434\U0001f428"
Как видно, каждый глиф состоит на самом деле из двух символов. Результат вызова метода CTRunGetStringIndices будет: [0,2,4].

Обратите внимание, что этот метод есть в двух вариантах: скопировать в выделенный заранее буфер нужное количество индексов или получить указатель на начало массива внутри CTRun. Выбирайте, что Вам удобнее. Таких методов довольно много, это видно из сигнатур, и я не буду заострять на этом внимание.

CFDictionaryRef CTRunGetAttributes(CTRunRef run)

Текущие атрибуты для CTRun, которые были заданы в CFAttributedString.

CTRunStatus CTRunGetStatus(CTRunRef run)

В виде битовой маски возвращает специфичные для отображения параметры. Обратите особое внимание на kCTRunStatusRightToLeft – показывает, что глифы в CTRun имеют направление написания справа налево.

const CGPoint* CTRunGetPositionsPtr(CTRunRef run)
void CTRunGetPositions(CTRunRef run, CFRange range, CGPoint buffer[])

Позиции для глифов внутри CTRun относительно CTFrame. Особое внимание обращаю на то, что если Вы будете пользоваться вторым вариантом метода и захотите взять позиции, скажем, второго глифа, то в range нужно передать не 1 (нумерация начинается с 0), а позицию этого глифа в строке. Для варианта со смайликами, который был выше, это была бы позииция 2. Это касается всех аналогичных методов.

const CGSize* CTRunGetAdvancesPtr(CTRunRef run)
void CTRunGetAdvances(CTRunRef run, CFRange range, CGSize buffer[])

Возращает размеры глифов.

double CTRunGetTypographicBounds(CTRunRef run, CFRange range, CGFloat* ascent, CGFloat* descent, CGFloat* leading)

Возвращает типографские параметры для отдельного CTRun. Помните я говорил, что для строки descent и ascent будут браться как крайние для шрифтов, которые были использованы в этой строке? Так вот, теперь для каждой части строки их можно узнать отдельно:


Еще, когда стало понятно что такое descent и ascent я расскажу о атрибуте kCTRunDelegateAttributeName. На вход он принимает CTRunDelegate, который создается на основе ряда методов:
typedef struct {
	CFIndex				version;
	CTRunDelegateDeallocateCallback	dealloc;
	CTRunDelegateGetAscentCallback	getAscent;
	CTRunDelegateGetDescentCallback	getDescent;
	CTRunDelegateGetWidthCallback	getWidth;
} CTRunDelegateCallbacks;

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

void CTRunDraw(CTRunRef run, CGContextRef context, CFRange range)

Нарисовать CTRun в графический контекст.

CoreGraphics


Уровень пятый. Глифы

Откровенно говоря, этот уровень уже не CoreText, а CoreGraphics — он был доступен и раньше. Просто сейчас удобнее стало доставать глифы.

CFIndex glyphCount = CTRunGetGlyphCount(run);
CGPoint positions[glyphCount];
CGGlyph glyphs[glyphCount];
            
CTRunGetPositions(run, CFRangeMake(0, 0), positions);
CTRunGetGlyphs(run, CFRangeMake(0, 0), glyphs);
            
CGContextSetFont(context, cgFont);
CGContextSetFontSize(context, CTFontGetSize(runFont));
CGContextSetFillColorWithColor(context, runColor);
CGContextShowGlyphsAtPositions(context, glyphs, positions, glyphCount);

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

Apple, как всегда, верна своим традициям в проектировании API: для простых вещей можно далеко не лезть и просто вызвать метод “сделайМнеХорошо”, а для более сложных есть возможность покрутить веревочки и подергать колесики.
  • +29
  • 6,8k
  • 9
Viber
44,00
Компания
Поделиться публикацией

Комментарии 9

    0
    Спасибо, очень полезно. Заметил, что платформа популярная, а статей на Хабре про iOS программирование крайне мало.
    Про вывод текста понятно, хочется ещё узнать по поводу ввода.
    Фреймворки или отдельные Html редакторы для собственных приложений уже появились?
      +3
      Хочу сказать, что статей мало, так как есть очень хорошая официальная документация. Даже все, что есть в этой статье просто отлично разжевано в документации, с картинками, примерами, кусками кода и целыми проектами.
        +1
        Конечно, все что здесь написано в конечном итоге почерпнуто из внешних источников: документации, WWDC, википедии. Право, как же может быть иначе, если приходится использовать готовое API?
        Документация у эппла, не побоюсь этого слова, великолепна, но мне просто в один момент захотелось как-то систематизировать и структурировать собственные знания и описать то, что пригодилось в реальной жизни. Мне будет приятно, если кому-то это будет полезно.
          +1
          Не подумайте, я не имею ничего против — такие статьи однозначно большой плюс и я не считаю их лишними, я просто рассказал почему их мало.
        +1
        хочется ещё узнать по поводу ввода
        Это очень интересная и довольно сложная тема. Если в общем, то делается контрол, который реализует методы протоколов UITextInput и UITextInputTraits. Таким образом мы перехватывает стандартные события аля insertText -> меняем текстовое представление -> перерисовываем. Все осложняется тем, что есть языки с направлением написания справа на лево и их смеси с традиционным написанием, иероглифы, тайский язык.
        Фреймворки или отдельные Html редакторы для собственных приложений уже появились?
        У нас, конечно, есть свои контролы для использования CoreText, а в HTML редакторе у нас не было нужды, если я верно понял Ваш вопрос.
          +3
          <sarcasm>
          >> Заметил, что платформа популярная,
          image

          >> а статей на Хабре про iOS программирование крайне мало.
          habrahabr.ru/hub/ios_dev/ 569 постов
          </sarcasm>
          0
          Открывая статью, я думал что будет вольный пересказ документации. Как же порадовало содержание! И подробно, и с примерами, и явно из личного опыта! Спасибо большое за такую работу, побольше бы таких статей на хабре!
            +1
            спасибо за познавательную и полезную статью
            однако хочу сделать одно замечание: на ios 5 данная конструкция
            NSDictionary *textAttributes = @{(NSString *)kCTFontAttributeName : [UIFont systemFontOfSize:16]};
            является некорректной, ибо CoreText не умеет работать с UIFont и при попытке отобразить строку с такими атрибутами вылетает EXC_BAD_ACCESS.
            Вместо этого необходимо применять CTFont, который не является toll-free bridged по отношению к UIFont. Вот пример конвертации который я применил в своем случае:
            UIFont *boldFont = [UIFont boldSystemFontOfSize:fontSize]; NSDictionary * boldAttrs = @{(NSString *)kCTFontAttributeName:(__bridge id)CTFontCreateWithName((__bridge CFStringRef)boldFont.fontName, boldFont.pointSize, NULL)};
              0
              Спасибо за ценное замечание. Хотелось сделать код несколько более читаемым и не загружать примеры созданиями промежуточных сущностей и совсем выскочило из головы. Я сейчас внесу этот момент в статью.

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

            Самое читаемое