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

в 9:19, , рубрики: ios development, Блог компании Viber, Песочница, разработка под iOS, метки:

Я хотел бы Вам рассказать про вывод текста на экран в 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];

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

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

  • 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
};

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

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

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

Первый уровень. 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)});
 

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

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

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

В последнем примере также используется параметр 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);

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

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

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

Лирическое отступление. 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);

Вывод текста в iOS: CoreText, NSAttributedString
Красный цвет — 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[])

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

Начиная с iOS6.0 CTrun будет следующим:

CTRun: string range = (0, 6), string = "U0001f437U0001f434U0001f428"

Как видно, каждый глиф состоит на самом деле из двух символов. Результат вызова метода 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 будут браться как крайние для шрифтов, которые были использованы в этой строке? Так вот, теперь для каждой части строки их можно узнать отдельно:
Вывод текста в iOS: CoreText, NSAttributedString

Еще, когда стало понятно что такое 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: для простых вещей можно далеко не лезть и просто вызвать метод “сделайМнеХорошо”, а для более сложных есть возможность покрутить веревочки и подергать колесики.

Автор: prizzrak

Источник


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


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