Pull to refresh

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

Reading time6 min
Views5.8K
Добрый день, %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.
Tags:
Hubs:
+15
Comments22

Articles

Change theme settings