Кастомизация заголовка окна в Mac OS X

  • Tutorial
Добрый день, %username%!

Не так давно пришла необходимость в кастомизации заголовка окна своей программы в Mac OS X. Если это делают iCal.app и Adress Book.app, то почему бы и мне не сделать так же?

Первые же ссылки из гугла дали мне несколько зацепок, и даже одна тестовая программа (после долгих плясок с бубном) скомпилилась и отобразила свой нестандартный заголовок. Но она требовала подключения приватных хедеров, их модификации (для соответствия новой версии Mac OS X) и т.п… А мне хотелось лучшего, хотелось сделать проще, да ещё и задать цвет текста заголовка окна (для гармонии с новым цветом заголовка). Отбросив все неудачные примеры, начал я копать зацепки…

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

Осторожно! Под катом присутствует магия рантайма.

Для начала нам понадобится приватный хедер NSThemeFrame.h (не оригинальный, а реверснутый, разумеется), его легко нагуглить. Если лень, то вот прямая ссылка. Его не обязательно добавлять в проект, он нам нужен только для изучения.

Пробежавшись по нему глазами, обратим внимание на методы drawRect: и _drawTitleStringIn:withColor:. Названия говорящие, вот их-то мы и будем перегружать, дабы полностью контролировать отрисовку окна. Вооружившись <objc/runtime.h>, начинаем.

Во-первых, нам надо как-то получить класс NSThemeFrame. Можно его получить из приватного хедера, но это плохой вариант. Допустим, в AppDelegate мы имеем аутлет нашего NSWindow, тогда, чтобы получить нужный класс, делаем так:

id _class = [[[self.window contentView] superview] class];

Почему? Потому что NSThemeFrame является базовым View в окне, а наш contentView расположен уже на нём.

Во-вторых, переходим к магии.

Нам требуется объявить свой класс, в нём — методы drawInRect: и _drawTitleStringIn:withColor:, затем добавить в класс NSThemeFrame эти методы (но под другими именами), и, наконец, обменять методы местами с оригинальными, чтобы иметь возможность из новых вызывать оригинальные.

Сложно звучит? Ну, рантайм в помощь!

Объявим вспомогательный класс DrawHelper (напрямую он использоваться не будет, так что не обращаем внимания на warning при компиляции).

#import <objc/runtime.h>

// global frame color
static NSColor * gFrameColor = nil;
// global title color
static NSColor * gTitleColor = nil;

@interface DrawHelper : NSObject
{
}

// to prevent errors
- (float)roundedCornerRadius;
- (void)drawRectOriginal:(NSRect)rect;
- (void) _drawTitleStringOriginalIn: (NSRect) rect withColor: (NSColor *) color;
- (NSWindow*)window;
- (id)_displayName;
- (NSRect)bounds;
- (void)_setTextShadow:(BOOL)on;

- (void)drawRect:(NSRect)rect;
- (void) _drawTitleStringIn: (NSRect) rect withColor: (NSColor *) color;
@end

@implementation DrawHelper

- (void)drawRect:(NSRect)rect
{
	// Call original drawing method
	[self drawRectOriginal:rect];
	[self _setTextShadow:NO];
	
	NSRect titleRect;

	NSRect brect = [self bounds];

	// creating round-rected bounding path
	float radius = [self roundedCornerRadius];
	NSBezierPath *path = [NSBezierPath alloc];
	NSPoint topMid = NSMakePoint(NSMidX(brect), NSMaxY(brect));
	NSPoint topLeft = NSMakePoint(NSMinX(brect), NSMaxY(brect));
	NSPoint topRight = NSMakePoint(NSMaxX(brect), NSMaxY(brect));
	NSPoint bottomRight = NSMakePoint(NSMaxX(brect), NSMinY(brect));
	
	[path moveToPoint: topMid];
	[path appendBezierPathWithArcFromPoint: topRight
					   toPoint: bottomRight
						radius: radius];
	[path appendBezierPathWithArcFromPoint: bottomRight
					   toPoint: brect.origin
						radius: radius];
	[path appendBezierPathWithArcFromPoint: brect.origin
					   toPoint: topLeft
						radius: radius];
	[path appendBezierPathWithArcFromPoint: topLeft
					   toPoint: topRight
						radius: radius];
	[path closePath];
	
	[path addClip];
	
	// rect for title
	titleRect = NSMakeRect(0, 0, brect.size.width, brect.size.height);
	
	// get current context
	CGContextRef context = (CGContextRef)[[NSGraphicsContext currentContext] graphicsPort];
	// multiply mode - for colorizing original border
	CGContextSetBlendMode(context, kCGBlendModeMultiply);
	
	// draw background
	if (!gFrameColor)
		// default bg color
		gFrameColor = [NSColor colorWithCalibratedRed: (126 / 255.0) green: (161 / 255.0) blue: (177 / 255.0) alpha: 1.0];
	
	[gFrameColor set];
	
	[[NSBezierPath bezierPathWithRect:rect] fill];
	
	// copy mode - for title
	CGContextSetBlendMode(context, kCGBlendModeCopy);
	
	// draw title text
	[self _drawTitleStringIn: titleRect withColor: nil];
}

- (void)_drawTitleStringIn: (NSRect) rect withColor: (NSColor *) color
{
	if (!gTitleColor)
		// default text color
		gTitleColor = [NSColor colorWithCalibratedRed: 1.0 green: 1.0 blue: 1.0 alpha: 1.0];
	[self _drawTitleStringOriginalIn: rect withColor: gTitleColor];
}

@end

Здесь всё достаточно просто. Объявляем два цвета — цвет заголовка и цвет текста, объявляем наш класс, в нём — кучу методов, которые нам нужны (имплементировать их не надо, в NSThemeFrame они есть) и, собственно, наши два метода для отрисовки текста и фона.

Для простоты примера я сделал отрисовку стандартного заголовка и «колоризацию» его одним цветом (это позволяет простым способом сохранить привычную «объёмность» заголовка). Можно сделать и полностью кастомную отрисовку, используюя NSImage или градиенты, при этом даже не обязательно вызывать drawRectOriginal:, ибо тогда нам не нужен будет стандартный заголовок. Но это оставим для самостоятельных упражнений.

После вызова стандартного метода отрисовки заголовка, переходим к созданию нашей области рисования. Обычно это прямоугольник со скруглёнными углами. Реализацию для других типов окон (например, с нескруглёнными нижними углами) оставляю так же для самостоятельной работы.

Ну а потом идёт отрисовка нашего цвета поверх уже отрисованного стандартного заголовка в режиме multiply (подробнее о режимах можно почитать в документации от Apple).

И в самом конце мы рисуем наш текст заголовка. Опять вызывается наша функция, которая игнорирует переданный в неё цвет и насильственно рисует текст заранее заданным цветом (через оригинальную функцию рисования).

И вот мы добрались до самого интересного! Собственно, магия:

- (void)applicationWillFinishLaunching:(NSNotification *)aNotification
{
	id _class = [[[self.window contentView] superview] class];
	
	// Exchange drawRect:
	Method m0 = class_getInstanceMethod([DrawHelper class], @selector(drawRect:));
	class_addMethod(_class, @selector(drawRectOriginal:), method_getImplementation(m0), method_getTypeEncoding(m0));
	
	Method m1 = class_getInstanceMethod(_class, @selector(drawRect:));
	Method m2 = class_getInstanceMethod(_class, @selector(drawRectOriginal:));
	
	method_exchangeImplementations(m1, m2);
	
	// Exchange _drawTitleStringIn:withColor:
	Method m3 = class_getInstanceMethod([DrawHelper class], @selector(_drawTitleStringIn:withColor:));
	class_addMethod(_class, @selector(_drawTitleStringOriginalIn:withColor:), method_getImplementation(m3), method_getTypeEncoding(m3));
	
	Method m4 = class_getInstanceMethod(_class, @selector(_drawTitleStringIn:withColor:));
	Method m5 = class_getInstanceMethod(_class, @selector(_drawTitleStringOriginalIn:withColor:));
	
	method_exchangeImplementations(m4, m5);
}

(в моём случае, я поместил этот код в AppDelegate.m, дабы быть уверенным, что окно уже будет создано)

По порядку:

1. получаем класс NSThemeFrame
2. берём метод drawRect: из класса DrawHelper
3. добавляем этот метод в класс NSThemeFrame под именем drawRectOriginal:
4. берём из класса NSThemeFrame методы drawInRect: и drawRectOriginal:
5. меняем их имплементации местами!

Далее то же самое делаем для метода _drawTitleStringIn:withColor:.

И вот теперь можем радоваться! Наше окно радует (или не очень) наш глаз своим нестандартным цветом заголовка.

Если очень хочется сделать некое «скинирование» (смена цвета заголовка на лету), то класс DrawHelper и содержимое функции applicationWillFinishLaunching: надо вынести в отдельный .m файл, а так же объявить и реализовать функции доступа к gFrameColor и gTitleColor. И не забыть перерисовать все свои окна после изменения этих параметров. Но это, опять таки, оставлю читателю в качестве самостоятельной работы.

Но, как и стоило бы ожидать, у данного подхода есть минусы:

1. для получения класса NSThemeFrame нам потребуется уже созданное окно;
2. данный способ не предполагает раздельной кастомизации окон, к примеру, нельзя сделать два окна с разными заголовками (конечно, можно, но это потребует достаточно много усилий и достаточно много кода);
3. окна могут отрисовываться в обход NSThemeFrame, например, с помощью NSGrayFrame, тогда данный способ, скорее всего, не поможет, и придётся играть ещё и со вторым классом;
4. игры с рантаймом хороши в меру.

PS: изначально всё это делалось в связке Qt + Cocoa, но было перенесено на чистый Cocoa. Если кому-либо интересны хитрости взаимодействия Qt с Cocoa, то могу поделиться опытом.

PPS: выкладывать код на гитхаб не вижу смысла, он очень легко переносится в любой проект простым копипастом в AppDelegate.m.
Метки:
Поделиться публикацией
Похожие публикации
Комментарии 22
  • 0
    В App Store, интересно, пропустят, если использовать приватные API? Что-то мне подсказывает, что нет
    • 0
      В AppStore достаточно много программ, использующих нестандартные заголовки. Не могу судить, пользовались ли они такими методами, но не исключаю такой возможности.
      • 0
        А вы не пробовали смотреть реализацию такого, скажем, в Google Chrome? Может, есть какой-то более-менее «прямой» способ без реверс-инженеринга внутренних классов? Ибо в Apple оооочень просят не юзать недокументированные API, потому что их приложение может легко сломаться в следующих версиях ОС (это касается больше iOS, но для Mac OS X тоже справедливо, в общем-то)
        • +1
          > это касается больше iOS
          Они не просят, они просто отклоняют такие приложения
          • 0
            Ну вот здесь автор, вроде как, не использует недокументированные API напрямую, а просто берет superView от самого верхнего view, доступного «просто так» — такое, ИМХО, непросто отловить при автоматическом анализе приложения. Ну а на использование method_exchangeImplementations, насколько я помню, не было ограничений вплоть до iOS 5, в котором, вроде как, исходя из комментариев к этому топику, его больше не рекомендуют использовать.
            • +1
              Я не это имел ввиду, такие фокусы я с UIWebView делал, не реджектили.

              А тут автор использует

              class_addMethod
              method_exchangeImplementations

              За которые в аппсор не ускают.
      • +4
        1. Приватные API в этом примере никто не использует. Имеет место быть рантайм подстановка стандартного метода drawRect: родительского объекта. _drawTitleStringIn — приватный метод, его подстановка — не очень красивое решение, без него, кстати, можно легко обойтись тем же drawRect:

        2. Пропустят. Моё приложение в AppStore использует этот принцип для кастомизации окна. BookReader, если что.
      • +1
        Было бы интересно почитать про хитрости взаимодействия Qt с Cocoa
        • +1
          А в чём сложность это сделать «легально», просто используя несложный подкласс NSWindow? Примеров масса. В Адресная книга и iCal, я думаю, так и реализовано.

          Т.е. смысл использования недокументированного API в данном случае сомнителен (по крайней мере для меня).

          • +1
            Возможно, дело именно в том, что автор юзает не просто Cocoa, а Qt+Cocoa и у него какие-то сложности с тем, чтобы задавать другой класс вместо NSWindow..?
            • 0
              Тут вопрос в простоте. Реализовывать свой сабкласс от NSWindow было быдостаточно дорогим удовольствием, кроме того, для внедрения его в уже готовый проект потребовалось бы много времени.

              И да, как заметили ниже, я исходил из ограничений Qt, и очень не хотелось модифицировать пересобирать весь фреймворк ради изменения цвета заголовка окна.
              • +1
                Сложность вот в чем: если вы используете borderless window, окно перестаёт быть resizable. Для MacOS X 10.4-10.6 это не было проблемой, так как ресайзер находился в нижнем правом углу и было раз плюнуть прилепить свой обработчик ресайза. В MacOS X 10.7 Lion ресайз окна происходит со всех сторон, при этом при наведении мыши на край окна нужно менять курсор на стрелочки. Я не хочу сказать, что это невозможно, это возможно, но геморройно. Легче подменить райнтайм метод и сделать все что нужно, даже убрать titlebar.
                • –2
                  Я детально не изучал этот вопрос. Но гугл в первых же ссылках приводит пример — github.com/indragiek/INAppStoreWindow.

                  Небольшой подкласс NSWindow со всеми вытекающими. Пример программы оттуда же работает без каких-либо проблем.
                  • +1
                    Сорри, но вы не в теме. Я не знаю какой вам кайф писать о том, в чем не разбираетесь, но хоть бы удосужились посмотреть что делает пример по вашей ссылке.
                    А делает он в основном следующее:

                    NSView *themeFrame = [contentView superview]; // берем NSThemeFrame
                    NSView *firstSubview = [[themeFrame subviews] objectAtIndex:0]; // берем первую дочернюю NSView
                    [_titleBarView setAutoresizingMask:(NSViewMinYMargin | NSViewWidthSizable)];
                    [self _recalculateFrameForTitleBarView];
                    [themeFrame addSubview:_titleBarView positioned:NSWindowBelow relativeTo:firstSubview]; // добавляем самодельный тайтлбар к NSThemeFrame под первой дочерней NSView

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

                    • 0
                      Ух, как Вы грозно. Я не понимаю, зачем быть в какой-то «теме», чтобы увидеть, что первый же приведённый пример по ссылке — это NSWindow с полностью кастомным «легальным» заголовком, и он ровно опровергает Ваши доводы про «ресайз» в 10.7.

                      Не кипятитесь, я нисколько не сомневаюсь в Вашей компетенции, уступлю (если это так важно) — я не гордый.
                      • 0
                        lymes просто показал, что приведённый Вами способ использует всё тот же NSThemeFrame, но при этом не делается никаких проверок. Тут тоже, можно сказать, используются недокументированные возможности — как минимум, некоторые факты из жизни NSThemeFrame. Хоть и неявно.
              • +1
                Неа, не опровергает. Объясню почему: в приведенном вами примере не используется borderless window, а используется некий костыль, прилепленный к тому же приватному NSThemeFrame.
                Этот костыль (кастомнй тайтлбар на основе NSView),
                — добавляет оверхед в интерфейс,
                — точно так же затрагивает приватную часть окна (NSThemeFrame)

                Чистое же решение, классический пример кастомизации NSWindow — это объект с флагом NSBorderlessWindowMask в маске стилей. И в этом случае код имплементации окна для Лайон действительно займёт намного больше.

                • 0
                  И да, чуть не забыл, в Лайон borderless окна не могут быть full-screen. Из-за этого приведенный автором статьи пример кастомизации окна остаётся чуть ли не единственно приемлемым.
                • +1
                  Очень интересно про Qt. У меня здоровенная софтина на Qt и хочется ей кастомизировать заголовок.

                  А пока, увы, даже иконку сделать не получается =((
                  • 0
                    Иконка делается элементарно.

                    Если это иконка самого приложения (которая будет отображаться в Finder'е), то в .pro файле надо написать что-то типа того:

                    macx: ICON = myicon.icns
                    

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

                    Если же нужно установить иконку для конкретного окна, то всё ещё проще — QWidget::setWindowIcon() или QApplication::setWindowIcon().

                    А вообще, это очень хорошо описано в документации.
                  • +2
                    Яхууу! Спасибо, не буду париться написанием своего солюшена :)
                    • 0
                      Хм, у меня при двойном щелчке на заголовок валится куча исключений вроде этого:
                      (ОС — 10.7)

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