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

Первые же ссылки из гугла дали мне несколько зацепок, и даже одна тестовая программа (после долгих плясок с бубном) скомпилилась и отобразила свой нестандартный заголовок. Но она требовала подключения приватных хедеров, их модификации (для соответствия новой версии Mac OS X) и т.п… А мне хотелось лучшего, хотелось сделать проще, да ещё и задать цвет текста заголовка окна (для гармонии с новым цветом заголовка). Отбросив все неудачные примеры, начал я копать зацепки…
И выяснил, что в обычной программе за отрисовку окна отвечает недокументированный класс NSThemeFrame, с ним-то мы и будем работать.
Осторожно! Под катом присутствует магия рантайма.
Для начала нам понадобится приватный хедер NSThemeFrame.h (не оригинальный, а реверснутый, разумеется), его легко нагуглить. Если лень, то вот прямая ссылка. Его не обязательно добавлять в проект, он нам нужен только для изучения.
Пробежавшись по нему глазами, обратим внимание на методы drawRect: и _drawTitleStringIn:withColor:. Названия говорящие, вот их-то мы и будем перегружать, дабы полностью контролировать отрисовку окна. Вооружившись <objc/runtime.h>, начинаем.
Во-первых, нам надо как-то получить класс NSThemeFrame. Можно его получить из приватного хедера, но это плохой вариант. Допустим, в AppDelegate мы имеем аутлет нашего NSWindow, тогда, чтобы получить нужный класс, делаем так:
Почему? Потому что NSThemeFrame является базовым View в окне, а наш contentView расположен уже на нём.
Во-вторых, переходим к магии.
Нам требуется объявить свой класс, в нём — методы drawInRect: и _drawTitleStringIn:withColor:, затем добавить в класс NSThemeFrame эти методы (но под другими именами), и, наконец, обменять методы местами с оригинальными, чтобы иметь возможность из новых вызывать оригинальные.
Сложно звучит? Ну, рантайм в помощь!
Объявим вспомогательный класс DrawHelper (напрямую он использоваться не будет, так что не обращаем внимания на warning при компиляции).
Здесь всё достаточно просто. Объявляем два цвета — цвет заголовка и цвет текста, объявляем наш класс, в нём — кучу методов, которые нам нужны (имплементировать их не надо, в NSThemeFrame они есть) и, собственно, наши два метода для отрисовки текста и фона.
Для простоты примера я сделал отрисовку стандартного заголовка и «колоризацию» его одним цветом (это позволяет простым способом сохранить привычную «объёмность» заголовка). Можно сделать и полностью кастомную отрисовку, используюя NSImage или градиенты, при этом даже не обязательно вызывать drawRectOriginal:, ибо тогда нам не нужен будет стандартный заголовок. Но это оставим для самостоятельных упражнений.
После вызова стандартного метода отрисовки заголовка, переходим к созданию нашей области рисования. Обычно это прямоугольник со скруглёнными углами. Реализацию для других типов окон (например, с нескруглёнными нижними углами) оставляю так же для самостоятельной работы.
Ну а потом идёт отрисовка нашего цвета поверх уже отрисованного стандартного заголовка в режиме multiply (подробнее о режимах можно почитать в документации от Apple).
И в самом конце мы рисуем наш текст заголовка. Опять вызывается наша функция, которая игнорирует переданный в неё цвет и насильственно рисует текст заранее заданным цветом (через оригинальную функцию рисования).
И вот мы добрались до самого интересного! Собственно, магия:
(в моём случае, я поместил этот код в 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.
Не так давно пришла необходимость в кастомизации заголовка окна своей программы в 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.
