Who's Afraid of the Dark? - Making Dark Mode Work. Revised 10/28/2022
Dark Mode was a huge change for UI design, and unlike many previous UI incremental changes, this one is not 'the' new UI, but is a user selection, or a system generated change that switches with the rising and setting 'sun'. I first experienced something like Dark Mode with my old Garmin auto GPS unit. But in 2018, with macOS 10.14 (Mojave), Apple thrust it onto our desktops, as an option . Adored by many and reviled by some, the Dark Mode option quickly spread to the mobile world, in September of 2019 both iOS 13 and Android 10 added the option.
The nifty part of Dark Mode is that it is up to the user to choose whether they want to use it or not, and that it is almost entirely provided by the system. Now you will remember that with the rollout of Dark Mode, the support varied by program, some programs still stand-out with big sections of 'white' blazing despite many years, which is understandable with really old programs, but some commercial and regularly updated programs still fail to adopt truly Dark Mode appearance. Is your program like that? Some of mine are.
I'm in the process of major revisions to some of my programs and revising and refactoring my Xojo development templates, tools & utility classes. So I thought that it might be a good time to share my solutions to certain development problems, and share the reasoning.
Implementing Dark Mode is really pretty easy, start with selecting 'Supports Dark Mode' in the Xojo project's Shared Build Settings. Almost every UI element just automatically supports Dark Mode. However there are a number of things I've found that must be addressed differently than in the past.
- Any time you set the color of anything in the UI, you must use a ColorGroup, rather than a specific color. Color groups allow you to set the Light color and the Dark color, or use a system 'named' pair of colors, and let the system automatically switch to match the Dark Mode selection.
- Any time you draw in a visual element you need to give attention to the color(s) used depending upon the Dark Mode selection.
- Icons and Images, in most cases, need to be handled manually when Dark Mode requires different versions.
The complexity of Dark Mode springs from the fact that the system may change from Light Mode to Dark Mode, or back, at any moment, even while your program is up and running. The system's stuff and Xojo framework provided stuff tend to take care of themselves, but how does your stuff know to change?
Well, once you are using ColorGroups (with dual colors or named colors) everywhere you set colors, and using color.isDarkMode in your paint methods to determine drawing colors, you are a mostly there, as the Xojo framework and application framework will handle most of the change.
To handle everything else that needs to change, DesktopApplication provides the 'AppearanceChanged' event. So... how does this event handler then notify every class & which needs to know Dark Mode changed?
I have considered the problem, searched the Xojo forum and other online sources. I posted a question and got the feedback and suggestions of a number of individuals. Then I actually designed and implemented a few solutions to assess the fitness of each. I will here describe 3 approaches, and provide two different solutions in code, the first is more flexible and capable and computationally efficient, the second is far simpler for implementation.
Approaches
Approach 1 would be to implement the DesktopApplication.AppearanceChanged event handler, and in this handler to go through the list of open Windows and 'tell' each of them that the appearance changed. The mechanism might be implementing an interface which specifies a method, added to each window. The window's method would need to know every item (i.e. control) in the window which needs to somehow be notified of the appearance change or in some way updated to account for the appearance change.
Approach 2 would be to implement the DesktopApplication.AppearanceChanged event handler, and in this handler to go through a list of items (windows or controls) which have 'registered' that they need to know about appearance changes. This is computationally efficient as the list would include only items which 'need to know'. An interface would be a good way to assure that each of these items implements a consistemt method for appearance change notification. Registration (when created) and unregistration (when destroyed) could be implemented in the constructor and destructor of each item that 'needs to know'.
Approach 3 would be to implement the DesktopApplication.AppearanceChanged event handler, and in this handler to walk through the list of open Windows and controls in each window and 'tell' each of them that the appearance changed. Implement an interface, which specifies a method, added to each item which 'needs to know', to handle appearance change notification. Polymorphism allows us to know which of the numerous Windows and Controls implement the interface (ones that 'need to know') and allows us to notify them.
Approach #1 was advocated in the forum, however I feel that this is poorer than the other two approaches for a number of reasons. I'm going to use #1, #2, & #3 to indicate the three listed approaches as I compare the weaknesses of each below.
- #1 requires that EACH window 'know' which of the controls within it need to update for appearance changes, and how to updated or notify them. That means that adding such a control (or removing one) requires changes to the Windows' 'appearanceChanged' handling code. This makes each 'Window' an unwitting participant in managing each control's needs. These needs might arise when a control is modified and now needs to know about appearance changes. EVERY window which uses that control would need to have its code changed. This is an unnecessary entanglement and maintenance issue.
- #2 is more computationally efficient as ONLY items which register (at creation) their need to be apprised of appearance changes will be involved in the notification process. #2 is also more flexible as it can also notify non-visual classes of the appearance change, since any class implementing the needed interface could register the need. I can't immediately think of any reason a non-window/non-control class might need to know, but it is a possibility with this approach. The downside is that each class needs to 'register' and 'unregister' their 'need to know'. This is easy to handle in the constructor and destructors, but is additional code which must not be forgotten or there will be no notifications. This registration code also entangles each control with whatever is handling the list of registered classes.
- #3 implement an interface for AppearanceChange notifications. By implementing the interface a window or control will effectively identify that it 'needs to know' about appearance changes. No registration/unregistration required. A single method, specified in the implemented interface, allows a consistent method of notifying the class of the change of appearance and provides a compact and localized method to handle the specific appearance change driven adjustments the class needs to execute. This encapsulates the behavior in each class and avoids unnecessary entanglements to other objects. When the application receives an AppearanceChanged event, it simply walks the tree of windows, and all of the controls in each window. Any one of them which implements the interface will be notified of the change. The weaknesses of #3 are that 1) it will only work for visual elements because these are all that can be found by walking the 'window & controls' tree. 2) it is computationally inefficient because the window and controls tree will easily, in any sophisticated program, include thousands upon thousands of items, the vast majority of which will not need to know about the appearance change.
For the reasons listed above, I will NOT use approach #1. Approach #2 is efficient and flexible AND is a pattern you will see & use for other things, so I'll implement it here and provide sample code. Approach #3 is the least entangled and relies upon polymorphism and a list/tree of visual elements automatically maintained by the framework and OS to find and notify classes which 'need to know' at run-time. Though run-time inefficient, the frequency of appearance changes is relatively low (not multiple times per second) and already requires a few seconds to occur, so I don't see the time needed to walk the entire tree, even with very sophisticated programs, to be an issue. The simplified implementation requirements at individual controls and at the application-level make this path most desirable to me. Approach #3 is my preferred solution and is implemented and provided here too.
LDS_ApperanceChangeManager - Solution #2
So, just in case you are looking for a relatively simple solution to driving your stuff to respond to Dark Mode changes.
- Create a new singleton class 'LDS_AppearanceChangeManager'.
- Implement the AppearanceChanged event handler in the application and have it call the LDS_AppearanceChangeManager's AppearanceChanged method.
- Add to your project a Class Interface named LDS_DarkModeResponsive, with one method named 'AppearanceChanged'.
- In each of your controls, windows, and classes of any type which needs to adapt to the Dark Mode setting, add the 'LDS_DarkModeResponsive' interface and implement the 'AppearanceChanged' method.
So what does this code look like? Well let's start with the 'LDS_DarkModeResponsive' class interface.
Now the Singleton Class LDS_AppearanceChangeManager class:
With this code:
Private Sub Constructor()
End Sub
Public Shared Sub AppearanceChanged()
if(ResponsiveItems<>nil) then
for each dmr as LDS_DarkModeResponsive in ResponsiveItems
dmr.AppearanceChanged()
next
end
End Sub
Public Shared Sub RegisterResponsiveItem(item as LDS_DarkModeResponsive)
if(ResponsiveItems=nil) then
redim ResponsiveItems(-1)
end
ResponsiveItems.add(item)
End Sub
Public Shared Sub UnregisterResponsiveItem(item as LDS_DarkModeResponsive)
if(ResponsiveItems<>nil) then
var index as integer = ResponsiveItems.LastIndex
while index>=0
if(ResponsiveItems(index) = item) then
ResponsiveItems.RemoveAt(index)
end
index=index-1
wend
end
End Sub
Private Shared Property ResponsiveItems() As LDS_DarkModeResponsive
Now the DesktopApplication's AppearanceChanged event handler:
Sub AppearanceChanged() Handles AppearanceChanged
LDS_AppearanceChangeManager.AppearanceChanged()
End Sub
Now that is the machinery to add to your application. Not so much I think, 1) one class interface, 2) one singleton class and 3) one line in the DesktopApplication's AppearanceChanged event handler.
To actually handle the things which need to adapt to an appearance change. For each window, control or class, which needs to be responsive to DarkMode there are 4 steps.
- Add the LDS_DarkModeResponsive class interface in your window, control or other class
- Register with LDS_AppearanceChangeManager (typically in the constructor or Opening event handler)
- Unregister with LDS_AppearanceChangeManager (typically in the destructor or Closing event handler)
- Implement the AppearanceChanged to handle your item's specific Dark Mode/Light Mode change behavior
Most of my classes & controls either include an 'UpdateAppearance' method, which also happens to deal with all the 'isDarkMode' stuff, or implements an 'AppearanceChanged' method. Of course you might choose to define and implement an 'AppearanceChanged' event definition and event handler or similar method of handling the updates.
This implementation is quite light weight and easy to code. The reason that this is the FIRST implementation I'm SHOWING is that I think it is much clearer for a less-experienced developer exactly what is occurring, it is maximally flexible, it is computationally efficient, and it is a pattern you will likely use in other contexts. Your class registers itself when constructed, and is added to a list, it is removed from the list (by unregistering) when your class is destructed, and while it exists, any change to DarkMode will notify all registered classes of the change by a call to your class' AppearanceChanged method. Now for a second approach...
LDS_AppearanceChangeAutoManager - Solution #3
A little less flexible, but very generally completely sufficient solution is implemented in LDS_AppearanceChangeAutoManager. This solution requires no registration/unregistration, but will not be able to update any classes not in the Window & Controls tree. This is all that is required, 2 steps + 1 step in your application.
- Add the LDS_DarkModeResponsive class interface in your window, control or other class
- Implement the AppearanceChanged to handle your item's specific Dark Mode/Light Mode change behavior
- Call the AppearanceChanged method of the LDS_AppearanceChangeAutoManager class from the application's AppearanceChanged event handler
AppearanceChanged Event Handler code:
Sub AppearanceChanged() Handles AppearanceChanged
LDS_AppearanceChangeAutoManager.AppearanceChanged()
End Sub
So here the magic is in the LDS_AppearanceChangeAutoManager, which is a singleton class, with one function, 'AppearanceChanged' with the following code which just checks each application window and each control in each window, for each, if they are (i.e. implement) LDS_DarkModeResponsive, then the object's 'AppearanceChanged' method will be called.
Here is the code for LDS_AppearanceChangeAutoManager.AppearanceChanged.
for each aWin as DesktopWindow in app.Windows
if(aWin isa LDS_DarkModeResponsive) then
LDS_DarkModeResponsive(aWin).AppearanceChanged()
end
var aCtrl as Object
for index as integer = 0 to aWin.ControlCount-1
aCtrl = aWin.Control(index)
if(aCtrl isa LDS_DarkModeResponsive) then
LDS_DarkModeResponsive(aCtrl).AppearanceChanged()
end
next
next
Either of these solutions works well for Dark Mode responsiveness. I am using Solution #3 in my programs, until and unless something pops up which requires DarkMode responsiveness which is outside of the Window/Control tree of objects. I don't anticipate that.
Feel free to download the file with these classes (see the top of this article or the Table of Contents downloads section) and feel free to implement them in your programs. If you find a better way to do this, please share. We should all be learning and can all be teaching.