DISCLAIMER: This is a continuation of this article where I write about implementing a theme switch in an Angular App. If you haven't already read that, I recommend you to read this first.๐๐ป
Let's implement a Theme Switch ๐จ like the Angular Material Site
Siddharth Ajmera ๐ฎ๐ณ for Angular ใป Mar 19 '20 ใป 11 min read
That way you'll have more context of where I'm coming from.๐
This article is intended for an audience with mixed experience levels. I've still added a TL;DR; below ๐๐ป as a precautionary measure, in case you're just interested in a specific section.
TL;DR;
- Why Dark Theme in the Dark? ๐คท๐ปโโ๏ธ
- Determining when it's darkโก
-
Enter: the
AmbientLightSensor
Web Interface ๐ก - Using the
AmbientLightSensor
Web Interface - Trying it out ๐งช
- Next Steps ๐ฃ
- Closing Notes ๐
To start, we'll just continue with the same Angular App that we built in the last article.
Why Dark Theme in the Dark? ๐คท๐ปโโ๏ธ
So, as I mentioned in the previous article, Dark Mode is awesome and low-lit ambiances are the best suited for #DarkMode.
Now changing themes in our App based on user interaction is OK. But we can take it to the next level by intelligently switching themes, based on the ambient lighting conditions that the user is in.
And that's exactly what we're going to do in this article.
Determining when it's dark โก
Now you might think, how exactly do we determine that it's dark. Well, there's something called Illuminance that can help us do that.
According to Wikipedia:
Illuminance is a measure of the luminous flux spread over a given area.
One can think of luminous flux (which is measured in lumens BTW) as a measure of the total "amount" of visible light present, and the illuminance as a measure of the intensity of illumination on a surface.
"So in simpler terms, the luminous flux is inversely proportional to darkness."
For some reference, we'll use this table to determine the darkness:
Judging from the table above, it would be safe to consider that if the luminous flux is 10 or less, we're in a dark environment. But that is just a number that I've chosen. Feel free to chose a number between 10 - 20(or 50 if you like) based on your preference.
Okay, so we can determine whether the environments are light or dark based on the luminous flux. But how do we determine luminous flux? ๐ค
Enter: the AmbientLightSensor
Web Interface ๐ก
This is a cool new interface from the Sensor APIs that returns the current light level or illuminance of the ambient light around the hosting device.
The
AmbientLightSensor
object has a property namedilluminance
on it, that returns the current light level in lux of the ambient light level around the hosting device.
It would only work on devices that have the ambient light sensor(hardware) on them(obviously). With the help of this AmbientLightSensor
interface, our browsers can access the data collected by Ambient Light Sensors on devices. Cool, isn't it? ๐คฉ
What does that mean for us? Well, you can think of
illuminance
as the luminous flux incident on a surface.
Now we know how to get the illuminance
, and from the Illuminance Table we can determine whether the environment we're in is dark or light.
So, we'll consider the environment to be dark if illuminance
<= 10(again, this number is totally up to you), light otherwise.
Using the AmbientLightSensor
interface to access illuminance
is pretty straightforward and the usage is mentioned in this example on the MDN Docs.
But there are a lot of other things that we need to take care of while using this interface. Let's go through them one by one.
Feature Detection ๐ต๐ปโโ๏ธ:
This is to determine whether the browser that is running our App has the AmbientLightSensor
feature on it or not. To detect this, we can simply check:
if ("AmbientLightSensor" in window) {
// Yay! The Browser has what it takes
}
Handling Edge Cases:
Checking whether the browser supports a feature doesn't guarantee that everything will work as expected. There might be errors:
- While instantiation the sensor.
- While using it.
- When the user's permissions might be required to use the sensor.
- When the sensor type might not be supported by the device.
So all these scenarios would result in an error. So while using this interface, we'll have to cater to all these edge cases as well.
Now that we know what we're looking at, let's try to implement this in our App.
Using the AmbientLightSensor
Web Interface
Reading the illuminance
and handling all these edge cases is the major task that we should delegate to a service. So let's implement a service in Angular that will handle all these things for us.
The only output that we're going to expect from this service is an Observable that either gives us the illuminance
or an error message that we could show to the user. So let's do this. I'll name this service AmbientLightSensorService
.
Also, since this service would also rely on the window
object, let's provide it as a value so that we could then inject it as a dependency in our AmbientLightSensorService
.
So in our AppModule
:
app.module.ts
...
import { AmbientLightSensorService } from "./ambient-light-sensor.service";
@NgModule({
...
providers: [
AmbientLightSensorService,
{
provide: Window,
useValue: window,
},
...
]
})
export class AppModule {}
There are also a lot of messages, error types, sensor policy and sensor name etc. that we are going to deal with. So let's also expose them as constants:
common.const.ts
export const SENSOR_NAME = "AmbientLightSensor";
export const SENSOR_POLICY_NAME = "ambient-light-sensor";
export const ACCESS_DENIED = "denied";
export const THEME_OPTIONS_URL = "/assets/options.json";
export const THEME_BASE_PATH = "node_modules/@angular/material/prebuilt-themes";
export const STYLE_TO_SET = "theme";
export const DARK_THEME = "pink-bluegrey";
export const LIGHT_THEME = "deeppurple-amber";
export const ERROR_TYPES = {
SECURITY: "SecurityError",
REFERENCE: "ReferenceError",
NOT_ALLOWED: "NotAllowedError",
NOT_READABLE: "NotReadableError"
};
export const ERROR_MESSAGES = {
UNSUPPORTED_FEATURE: "Your browser doesn't support this feature",
BLOCKED_BY_FEATURE_POLICY:
"Sensor construction was blocked by a feature policy.",
NOT_SUPPORTED_BY_USER_AGENT: "Sensor is not supported by the User-Agent.",
PREMISSION_DENIED: "Permission to use the ambient light sensor is denied.",
CANNOT_CONNECT: "Cannot connect to the sensor."
};
Hopefully, I've named these variables in a way that they are self-explanatory.
Now let's implement this service:
ambient-light-sensor.service.ts
import { ReplaySubject, Observable } from "rxjs";
import { Injectable } from "@angular/core";
import {
SENSOR_NAME,
SENSOR_POLICY_NAME,
ACCESS_DENIED,
ERROR_TYPES,
ERROR_MESSAGES
} from "./common.const";
@Injectable()
export class AmbientLightSensorService {
private illuminance: ReplaySubject <number> = new ReplaySubject <number>(1);
illuminance$: Observable<number> = this.illuminance.asObservable();
constructor(private window: Window) {
try {
if (SENSOR_NAME in window) {
this.startReading();
} else {
this.illuminance.error(ERROR_MESSAGES.UNSUPPORTED_FEATURE);
}
} catch (error) {
// Handle construction errors.
if (error.name === ERROR_TYPES.SECURITY) {
this.illuminance.error(ERROR_MESSAGES.BLOCKED_BY_FEATURE_POLICY);
} else if (error.name === ERROR_TYPES.REFERENCE) {
this.illuminance.error(ERROR_MESSAGES.NOT_SUPPORTED_BY_USER_AGENT);
} else {
this.illuminance.error(`${error.name}: ${error.message}`);
}
}
}
private startReading() {
const sensor = new AmbientLightSensor();
sensor.onreading = () => this.illuminance.next(sensor.illuminance);
sensor.onerror = async event => {
// Handle runtime errors.
if (event.error.name === ERROR_TYPES.NOT_ALLOWED) {
// Branch to code for requesting permission.
const result = await navigator.permissions.query({
name: SENSOR_POLICY_NAME
});
if (result.state === ACCESS_DENIED) {
this.illuminance.error(ERROR_MESSAGES.PREMISSION_DENIED);
return;
}
this.startReading();
} else if (event.error.name === ERROR_TYPES.NOT_READABLE) {
this.illuminance.error(ERROR_MESSAGES.CANNOT_CONNECT);
}
};
sensor.start();
}
}
The implementation caters to every edge case that we discussed in the previous section.
Basically, we've exposed the illuminance
ReplaySubject<number>
as the illuminance$
Observable<number>
.
"Why a ReplaySubject<number>(1)
?" you might ask. Well, because we don't have an initial value and so it would make more sense to use that instead of using BehaviorSubject<number>(null)
.
Now, we're pushing a new lux values down the illuminance
ReplaySubject
by calling the next
method on it. And for the error cases, we're pushing out an error using the error
method.
The method names and error message names are pretty self-explanatory as well. If something is still not clear, please comment down below so that I can elaborate more on it.
And so now that the service is ready, we can inject this service as a dependency in our HeaderComponent
and leverage the illuminance$
Observable
to get access to the lux value(or the error message).
header.component.ts
import { Component, OnDestroy, OnInit } from "@angular/core";
import { MatSnackBar } from "@angular/material/snack-bar";
import { Observable, Subject } from "rxjs";
import { takeUntil } from "rxjs/operators";
import { AmbientLightSensorService } from "../ambient-light-sensor.service";
import { DARK_THEME, LIGHT_THEME } from "../common.const";
import { Option } from "../option.model";
import { ThemeService } from "../theme.service";
@Component({
selector: "app-header",
templateUrl: "./header.component.html",
styleUrls: ["./header.component.css"]
})
export class HeaderComponent implements OnInit, OnDestroy {
options$: Observable<Array<Option>> = this.themeService.getThemeOptions();
private unsubscribe$ = new Subject<void>();
constructor(
private readonly themeService: ThemeService,
private readonly alsService: AmbientLightSensorService,
private readonly snackBar: MatSnackBar
) {}
ngOnInit() {
this.themeService.setTheme(DARK_THEME);
this.alsService.illuminance$
.pipe(takeUntil(this.unsubscribe$))
.subscribe(
illuminance => {
illuminance <= 10
? this.themeService.setTheme(DARK_THEME)
: this.themeService.setTheme(LIGHT_THEME);
},
error => this.showMessage(error)
);
}
themeChangeHandler(themeToSet) {
this.themeService.setTheme(themeToSet);
}
ngOnDestroy() {
this.unsubscribe$.next();
this.unsubscribe$.complete();
}
private showMessage(messageToShow) {
this.snackBar.open(messageToShow, "OK", {
duration: 4000
});
}
}
So as you can notice:
- We've now injected the
AmbientLightSensorService
as a dependency. - In the
ngOnInit
lifecycle hook, we'resubscribe
ing to theObservable
. From here:- The success callback gets called with the
illuminance
value. Here we check theilluminance
value:- If it is
<= 10
, then we set theDARK_THEME
. - If it is
> 10
, then we set theLIGHT_THEME
.
- If it is
- The error callback gets called with the
error
message. From there, we're simply calling theshowMessage
method to show a snack bar.
- The success callback gets called with the
Also, since we're subscribe
ing to the Observable
this time around, we'll also have to explicitly do something to avoid any memory leaks. To do it, we go declarative by using the takeUntil
operator.
Read more about this approach in this article on AngularInDepth by Tomas Trajan
And that's it. Our AmbientLightSensor
Theme Switch is now ready. Let's test it out.
Trying it out ๐งช
Before we do that, there's a caveat. And it has something to do with browser support.
As you can see above, browser support isn't that great at the moment. But we'll at least test this on the best browser in the world(ahem Chrome ahem).
To do that, we'll first have to enable a flag:
So I'll navigate to chrome://flags/#enable-generic-sensor-extra-classes
and enable it on my phone(my laptop doesn't have Ambient Light Sensor on it). And then I'll restart the browser on my phone.
Let's now test this thing out:
And here's the final code:
Next Steps ๐ฃ
As of now, there's a slight issue in the App. What if the user doesn't want to change the theme automagically, based on the lighting conditions? We can add in a simple fix as a settings/preference menu asking to turn this behavior ON/OFF and switch the theme only when the behavior is turned ON.
Give it a try and implement the Preference/Settings Menu and then only switch the theme if the user has switched this auto-theme-switch behavior on.
Closing Notes ๐
Awwww! You're still here? Thanks for sticking around. I hope you liked it.
Iโm extremely grateful to Martina Kraus, and Rajat Badjatya for taking the time to proofread it and providing all the constructive feedback in making this article better.
I hope this article has taught you something new related to Angular and Web in general. If it did, hit that ๐งก/๐ฆ icon, and add it to your reading list(๐). Also, share this article with your friends who are new to Angular/Web and want to achieve something similar.
Please stay tuned while I work on uploading a video version of this article. ๐บ
Icon Courtesy: Angular Material by AngularIO Press Kit | CSS by monkik from the Noun Project | Light sensor by Adnen Kadri from the Noun Project
Until next time then. ๐๐ป