[VB.NET] Windows Forms 앱에서 밝게 (Light) / 어둡게 (Dark) 테마 설정에 대응하기

happybono
24 min readJun 29, 2019

최근 대부분의 제조사들이 여러가지 테마를 제공하려고 노력하는 추세입니다. 최근 Microsoft 도 Windows 10, Build 1903 버전부터 기본적으로 지원하던 어두운 테마 이외에 밝은 테마를 제공하기 시작했고, 그간 밝은 테마만을 지원하던 Apple 사의 iPhone 역시 iOS 13 버전부터 어두운 테마를 설정할 수 있도록 개선되었습니다.

이러한 테마의 다양화가 이루어지는 원인은 여러가지가 있겠지만, 몇 가지 장점이 되는 요소들을 나열해보자면,

  • 유기 LED (Organic Light Emitting Diode) 로 불리우는 OLED 화면의 특성상, 어두운 테마를 지원하게 되면 배터리 소모율을 절약할 수 있게 됩니다.
  • 어두운 테마를 지원하면서 밝은 테마 사용 시 사용자의 눈에 전달되던 블루라이트로 인한 피로를 줄여줄 수 있습니다.
  • 사용자가 원하는 스타일의 테마를 지원함으로서, 사용자의 입맛에 맞는 디자인에 대한 선택 폭이 넓어집니다.

이러한 장점에도 불구하고 개발자에게는 고민을 안겨줍니다. 2010 년에 출시되었던 Windows Phone 7.0 버전 이상의 Windows Phone (Mobile) 앱을 개발해보신 경험을 가지고 계신 분이시라면 아시겠지만, 이전 (Windows Phone 7.0 이전 버전 또는 Windows 10, Build 10240 이전 버전) 까지는 Windows Forms 앱 개발 과정에서 별도의 심사 과정이 필요치 않았을 뿐 더러, Windows 운영체제에 테마라는 개념 도입이 되지 않았던 시기였기에 큰 문제 없이 개발이 가능했으므로 처음 Windows Phone Marketplace (앱 스토어) 에 앱을 배포 한 후 심사 과정에서 시행착오를 하신 분들이 많습니다.

기본적으로 Windows Phone (Mobile) 7 버전 이상에서 설정된 테마는 어두운 (Dark) 테마였기에, 설정 메뉴에서 밝은 (Light) 테마로 변경할 경우 앱에 표시되어야 할 컨텐츠의 색상이 흰색으로 설정되어 사용자가 이를 밝은 테마 (하얀색 배경) 로 변경할 경우 컨텐츠를 확인할 수 없는 문제점이 생겨 Windows Store (舊 Windows Phone Marketplace) 앱 배포 및 출시 시 심사 과정에서 반려 (Reject) 되는 사례가 많았던 것으로 기억되는데요.

Windows Phone / Mobile 앱이나 UWP (Universal Windows Platform) 앱에서 이를 해결하기 위해서는 텍스트의 색상을 지정하기보다는, 개발 환경에서 미리 지정해 둔, Static Resource 의 색상들을 활용하거나 배경색을 정적으로 고정시키는 앱으로 개발하면 해결이 가능했습니다만, 후자의 경우 배경색 변경을 수행할 경우 이중으로 설정 값을 변경해주어야 하고, 일관적인 사용자 경험을 전달하지 못해 사용자가 만족할 만한 경험을 제공하지 못한다는 문제점도 공존한다는 것이 사실입니다.

특히 Windows Store 앱 개발 환경에서는 .xaml 환경에서 기본적으로 제공하는 테마 리소스를 통해 디자이너 뷰에서 간편하게 Style 속성 (Properties) 하나만 추가하더라도 테마가 변경되는 각기 상황에 따라 유연하게 대처할 수 있었습니다.

<Button Content="Button" HorizontalAlignment="Left" Margin="210,232,0,0" VerticalAlignment="Top" Height="35" Height="105" Style="{StaticResource AccentButtonStyle}"/>

[ Windows Store (UWP) 앱에서 RequestTheme 속성을 통해 테마 지원하기 ]

UWP 앱의 경우 App.xaml 파일을 수정하여 앱 내에서 지원하는 테마를 [Light] 혹은 [Dark] 와 같이 지정해 둘 수 있습니다. 해당 값을 제거하게 되면 기본 [Default] 값으로 동작하게 되며 총 네 가지 상태 값을 지원합니다.

[ ThemeResources 테마 리소스 적용 예시 ]

정적 색상으로 동작하지만 테마 설정에 따라 컨트롤의 색상을 지정해두어 각 테마 설정 값에 따라 다르게 적용되도록 하는 방법입니다.

StaticResource 와 비슷하게, ResourceDictionary 하위에 색상을 정의해 놓는 방법입니다. 런타임 시 테마를 변경하면 변경됩니다. 차이점이 존재한다면 ResourceDictionary.ThemeDictionaries 태그 내에 캡슐화시켜 정의하고 각각의 Key 를 사용하여 테마를 구분하여야 한다는 점입니다.

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:MySampleProject.ResourcesDictionaries"> <ResourceDictionary.ThemeDictionaries> <ResourceDictionary x:Key="Dark"> <Brush x:Key="MyButtonBrush">Red</Brush> </ResourceDictionary> <ResourceDictionary x:Key="Light"> <Brush x:Key="MyButtonBrush">Green</Brush> </ResourceDictionary> <ResourceDictionary x:Key="HighContrast"> <Brush x:Key="MyButtonBrush">Blue</Brush> </ResourceDictionary> </ResourceDictionary.ThemeDictionaries> </ResourceDictionary>

윗 코드를 버튼의 Background 속성으로 호출하여 색상 변경을 구현하는 예시입니다. ThemeResource 부분을 유념해서 보시면 되겠습니다.

<Button Content="Hello World" Click="HelloWorld_Click" Background="{ThemeResource MyButtonBrush}" />

다시 말해, ResourceDictionary 에서 Key 를 사용하여 테마를 판별하는 부분이 핵심입니다. Default 값을 입력 할 수도 있지만 이는 공식적으로 권장하지 않는 방법입니다. Microsoft의 ThemeResource에 대한 자세한 내용은 이 곳을 참고하시면 되겠습니다.

[ RequestedTheme 속성 사용하기 ]

각각의 FrameworkElement (XAML 요소의 기본 형식) 내에서 Microsoft 는 RequestTheme 속성을 제공하고 있습니다. 이는 XAML 파일 수정을 통해서 혹은 코드 상에서 다음과 같이 변경이 가능합니다.

<Button Content="Hello World Dark" Background="{ThemeResource MyButtonBrush}" RequestedTheme="Dark" /> <Button Content="Hello World Light" Background="{ThemeResource MyButtonBrush}" RequestedTheme="Light" />

위에서 언급한 app.xaml 내부의 RequestTheme 속성은 Global Setting (전역 설정) 으로 작동하므로 런타임 (Runtime) 시 설정을 변경할 경우 예외 오류가 발생하게 됩니다. 따라서, 이를 방지하기 위해 앱이 구동 (Start up) 될 때 색상을 설정하도록 구현하는 것이 좋습니다. 이는 사용자가 테마를 변경 한 후 앱을 종료하고 재시작하여야 변경된 값이 앱에 적용된다는 의미가 되겠지요.

이러한 단점을 보완하기 위해 운영체제 내 설정이 아닌, 앱 내에서 별도의 설정 항목을 통해 테마를 변경하는 경우 RequestTheme 속성을 앱의 최상위 프레임 (Root Frame) 단에서 변경하는 방법입니다. 이러한 방법으로 프로젝트 / 솔루션 내 포함된 모든 앱 페이지에 일괄적으로 테마가 적용되므로 가장 효율적인 방법일 수 있는데요.

저의 경우 스토어 앱 개발 시 Caliburn.Micro 라는 확장 기능을 주로 사용한다는 것을 감안하고 읽어주시면 좋겠습니다. app.xaml.vb 파일에 다음과 같이 Navigation Service 를 등록해둡니다.

Protected Overrides Sub PrepareViewFirst(ByVal rootFrame As Frame) _container.RegisterNavigationService(rootFrame) End Sub

현재 추가한 부분을 앱의 어떤 요소들에 적용할 수 있을지 확실하지 않은 상태이기 때문에 차후 문제 없이 접근할 수 있도록 app.xaml.vb 파일 내부에 Public Shared 값을 만들어두었습니다.

Public Shared MyRootFrame As Frame Protected Overrides Sub PrepareViewFirst(ByVal rootFrame As Frame) MyRootFrame = rootFrame _container.RegisterNavigationService(rootFrame) End Sub

ViewModel 에서 어떠한 요소 (컨트롤 들) 에 적용할 지 결정한 후에 다음과 같이 구현할 수 있습니다. 이제 앱의 상태에 따라 다음의 코드를 호출함으로써, 간단히 전환하고 테마를 변경할 수 있습니다.

Public Sub ToggleThemes() App.MyRootFrame.RequestedTheme = If(App.MyRootFrame.RequestedTheme = ElementTheme.Dark, ElementTheme.Light, ElementTheme.Dark) End Sub

몇 개월 전 까지만 하더라도 Windows Store 생태계를 확장하겠다는 목적으로 개발자들에게 모던 앱으로 불리우는 (舊 메트로 앱) Store 앱 배포를 강제했었는데요, 굳이 Windows Store 를 거치지 않아도 보다 간편한 절차를 통해 앱을 설치할 수 있도록 규제를 완화하겠다는 Microsoft 의 발표 에 따라 기존의 Windows Forms 형식의 프로젝트에서도 사용자가 운영체제의 테마 설정을 변경할 경우 앱에서 자동적으로 대응하여 색상을 변경하게 할 수 있는 방법이 존재하는지에 대해 개인적으로 고민하기 시작했습니다.

물론, Windows Forms 앱의 경우 Windows Store 앱에 포함되지 않으므로 Windows 앱 가이드라인에 맞추지 않더라도 배포 형식이나 절차에 있어 상대적으로 자유롭기에 별다른 제약이나 심사과정 없이 앱 배포가 가능합니다만, Windows Phone (Mobile) 및 UWP 앱을 개발 해 본 경험상 가이드라인은 존재하지 않더라도 앱 내에서 자동적으로 Windows 테마 설정에 대응함으로서 일관적인 사용자 경험을 전달할 수 있으므로 최상의 사용자 경험을 제공할 수 있을 것으로 보입니다.

이제 본론으로 들어가서, Windows Form 에서 테마 설정에 따른 앱 색상 변화를 어떻게 구현이 가능한지에 대해 알아보도록 하겠습니다.

[ Windows Forms 앱에서 설정된 테마 색상 적용하기 ]

전통적인 방법으로 레지스트리에 저장된 설정 값을 불러오는 함수를 생성한 다음 호출하여 테마 색상을 적용하는 방법으로 접근할 수 있었습니다.

테마 색상 값이 저장되는 레지스트리 경로는 HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize 디렉토리 하위의 “AppsUseLightTheme” 값에 이진값 형식으로 저장됩니다. (저장된 값이 ‘1’ 인 경우 밝은 테마로 설정되어 있는 상태를, ‘0’ 인 경우 어두운 테마로 설정되어 있는 상태를 의미합니다.)

Private Function WindowsTheme() As String If My.Computer.Registry.GetValue("HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "AppsUseLightTheme", 1) Then Return "Light" Else Return "Dark" End If End Function

아래와 같이 함수를 호출 하신 후 각 컨트롤의 종류에 따라 For Loop 반복문을 통해 BackColor 와 ForeColor 속성을 이용하여 색상 변경을 시도하는 방식으로 테마 적용까지 완료했습니다.

색상 값을 Color.FromArgb() 형식으로 일일이 입력해도 무방하지만, 편의 상 색상 값들을 호출하여 ReadOnly 필드 형식으로 선언해두었습니다.

ReadOnly DarkBackColour As Color = Color.FromArgb(0, 0, 0) ReadOnly DarkForeColour As Color = Color.FromArgb(255, 255, 255) ReadOnly DarkBackColourLight As Color = Color.FromArgb(64, 64, 64) Private Sub SetTheme() If WindowsTheme() = "Dark" Then Me.BackColor = Color.FromArgb(0, 0, 0) Me.ForeColor = Color.FromArgb(255, 255, 255) For Each c In Me.Controls Select Case c.GetType.ToString Case "System.Windows.Forms.GroupBox" c.ForeColor = DarkBackColour c.BackColor = DarkForeColour Case "System.Windows.Forms.Button" c.FlatStyle = FlatStyle.Flat c.BackColor = DarkBackColourLight c.ForeColor = DarkForeColour Case "System.Windows.Forms.Label" c.FlatStyle = FlatStyle.Flat c.BackColor = DarkBackColour c.ForeColor = DarkForeColour Case "System.Windows.Forms.TextBox" c.BorderStyle = BorderStyle.None c.BackColor = DarkBackColourLight c.ForeColor = DarkForeColour Case "System.Windows.Forms.ComboBox" c.FlatStyle = FlatStyle.Flat c.BackColor = DarkBackColourLight c.ForeColor = DarkForeColour End Select Next Else Me.BackColor = DefaultBackColor Me.ForeColor = DefaultForeColor For Each c In Me.Controls Select Case c.GetType.ToString Case "System.Windows.Forms.Button" c.FlatStyle = FlatStyle.System c.ForeColor = Color.FromArgb(0, 0, 0) Case "System.Windows.Forms.Label" c.FlatStyle = FlatStyle.System Case "System.Windows.Forms.TextBox" c.BorderStyle = BorderStyle.Fixed3D c.ForeColor = Color.FromArgb(0, 0, 0) c.BackColor = Color.FromKnownColor(KnownColor.Control) Case "System.Windows.Forms.GroupBox" c.ForeColor = Color.FromArgb(0, 0, 0) c.BackColor = DefaultBackColor End Select Next End If End Sub

이렇게 테마까지 세팅했는데, GroupBox 혹은 Panel 내에 있는 컨트롤들은 하나의 컬렉션 (Collection) 으로 동작하므로 적용되지 않는 문제점이 존재했습니다. 이를 해결하기 위해 다음과 같이 GroupBox 안의 컨트롤들을 서브루틴 (Subroutine) 을 통해 재귀적 (Recursive) 으로 만든 다음 해당 서브루틴 (Subroutine) 을 호출하여 최종적으로 해결할 수 있었습니다.

Private Sub SetControlsDark(ByVal ctrlCol As Control.ControlCollection) For Each c In ctrlCol Select Case c.GetType.ToString Case "System.Windows.Forms.Button" c.FlatStyle = FlatStyle.Flat c.FlatAppearance.BorderSize = 0 c.BackColor = DarkBackColourLight c.ForeColor = DarkForeColour Case "System.Windows.Forms.Label" c.BackColor = DarkBackColour c.ForeColor = DarkForeColour Case "System.Windows.Forms.GroupBox" c.BackColor = DarkBackColour c.ForeColor = DarkForeColour SetControlsDark(c.Controls) Case "System.Windows.Forms.ComboBox" c.FlatStyle = FlatStyle.Standard c.BackColor = DarkBackColourLight c.ForeColor = DarkForeColour Case Else c.BackColor = DarkBackColour c.ForeColor = DarkForeColour End Select Next End SubPrivate Sub SetControlsLight(ByVal ctrlCol As Control.ControlCollection) For Each c In ctrlCol Select Case c.GetType.ToString Case "System.Windows.Forms.Button" c.FlatStyle = FlatStyle.System c.FlatAppearance.BorderSize = 0 c.BackColor = DefaultBackColor c.ForeColor = DefaultForeColor Case "System.Windows.Forms.Label" c.FlatStyle = FlatStyle.System c.BackColor = DefaultBackColor c.ForeColor = DefaultForeColor Case "System.Windows.Forms.GroupBox" c.BackColor = DefaultBackColor c.ForeColor = DefaultForeColor SetControlsLight(c.Controls) Case "System.Windows.Forms.ComboBox" c.FlatStyle = FlatStyle.Standard c.BackColor = DefaultBackColor c.ForeColor = DefaultForeColor Case Else c.BackColor = DefaultBackColor c.ForeColor = DefaultForeColor End Select Next End SubPrivate Sub SetTheme() If WindowsTheme() = "Dark" Then Me.BackColor = Color.FromArgb(0, 0, 0) Me.ForeColor = Color.FromArgb(255, 255, 255) For Each c In Me.Controls Select Case c.GetType.ToString Case "System.Windows.Forms.GroupBox" c.ForeColor = DarkBackColour c.BackColor = DarkForeColour SetControlsDark(Me.Controls) Case "System.Windows.Forms.Button" c.FlatStyle = FlatStyle.Flat c.BackColor = DarkBackColourLight c.ForeColor = DarkForeColour Case "System.Windows.Forms.Label" c.FlatStyle = FlatStyle.Flat c.BackColor = DarkBackColour c.ForeColor = DarkForeColour Case "System.Windows.Forms.TextBox" c.BorderStyle = BorderStyle.None c.BackColor = DarkBackColourLight c.ForeColor = DarkForeColour Case "System.Windows.Forms.ComboBox" c.FlatStyle = FlatStyle.Flat c.BackColor = DarkBackColourLight c.ForeColor = DarkForeColour End Select Next Else Me.BackColor = DefaultBackColor Me.ForeColor = DefaultForeColor For Each c In Me.Controls Select Case c.GetType.ToString Case "System.Windows.Forms.Button" c.FlatStyle = FlatStyle.System c.ForeColor = Color.FromArgb(0, 0, 0) Case "System.Windows.Forms.Label" c.FlatStyle = FlatStyle.System Case "System.Windows.Forms.TextBox" c.BorderStyle = BorderStyle.Fixed3D c.ForeColor = Color.FromArgb(0, 0, 0) c.BackColor = Color.FromKnownColor(KnownColor.Control) Case "System.Windows.Forms.GroupBox" c.ForeColor = Color.FromArgb(0, 0, 0) c.BackColor = DefaultBackColor SetControlsLight(Me.Controls) End Select Next End If End Sub

사용자가 Windows 운영체제에서 설정한 테마 색상을 앱 내에 적용하기 위해 강조 색상 (Accent Color) 을 어떻게 추출해올까에 대한 답안이 명확하지 않아 인터넷으로 검색 해 보았습니다. 하지만, 인터넷에서 찾은 방법들을 시도해보았지만 실질적으로 작동하는 코드 예시를 찾기는 어려웠으며 대부분의 코드가 Windows Form 프로젝트에서 작동하지 않았는데요,

직접 시도해보기로 결정하고 몇 번의 시도 끝에 원하는 결과를 얻을 수 있었습니다. 당연하겠지만 비공식적인 방법이기에 문서화 되어 있지 않거나, 일반적이지 않은 방법은 구현하는 과정에서 최대한 피했습니다.

결국, 폼 상단에 activeColor 라는 이름으로 색상 (Color) 형식의 변수를 생성한 다음 레지스트리에 접근하여, HKEY_CURRENT_USER\Software\Microsoft\Windows\DWM 디렉토리 하위의 “AccentColor” 값을 추출하는 방법으로 시도해 성공할 수 있었습니다.

Dim accentColor As Color = System.Drawing.SystemColors.ActiveBorderPrivate Sub WindowsAccent() Try Dim regKey As RegistryKey = Registry.CurrentUser.OpenSubKey("Software\Microsoft\Windows\DWM") If (Not (regKey) Is Nothing) Then Dim obj As Object = regKey.GetValue("AccentColor") If (Not (obj) Is Nothing) Then accentColor = Color.FromArgb(getARGB(CType(obj, Integer))) End If End If Catch ex As Exception '예외 : 색상을 레지스트리 편집기로 부터 추출해 올 수 없습니다. End Try End Sub

주의해야 하는 부분이 존재한다면, 레지스트리에 저장된 값은 일반적으로 통용되는 ARGB 형식이 아닌, 전통적인 Win32 시스템에서 호환되는 AGBR 형식의 값으로 이루어져 있다는 점 입니다. 정확한 형식은 Color.FromArgb(int i) 와 같습니다. 따라서 비트 스와핑 (Bit Swapping) 을 통해 올바른 색상 값을 추출해올 수 있도록 올바른 형식으로 변형해주어야 합니다.

Private Function getARGB(iABGR As Integer) As Integer Return ((iABGR >> 24) << 24) _ Or ((iABGR >> 16) And &HFF) _ Or ((iABGR >> 8) And &HFF) << 8 _ Or ((iABGR) And &HFF) << 16 End Function

해결하는 방법에 있어 명확하고 가장 간결한 접근법은 아닐 수 있겠지만, 최소한 제가 해결하고자 했던 강조 색상 (Accent Color) 를 추출하고 적용하는데에는 효과가 있었습니다.

함수는 아래와 같이 호출할 수 있습니다. 색상 적용을 원하시는 컨트롤의 BackColor 속성이나 MouseOverBackColor 와 같은 색상 형식의 속성 값에 호출하여 적용하시면 Windows 테마 설정에서 선택한 강조 색상과 동일한 색상으로 변경됨을 확인하실 수 있습니다.

Button1.BackColor = accentColor Button1.FlatAppearance.MouseOverBackColor = accentColor

고맙습니다.

Originally published at http://happybono.wordpress.com on June 29, 2019.

--

--