react-native-unistyles
Version:
Level up your React Native StyleSheet
306 lines (267 loc) • 10.9 kB
text/typescript
import { NativeEventEmitter, NativeModules } from 'react-native'
import type { UnistylesThemes, UnistylesBreakpoints } from 'react-native-unistyles'
import type { ColorSchemeName, ScreenInsets, StatusBar, NavigationBar, Color } from '../types'
import { normalizeWebStylesPlugin } from '../plugins'
import { isServer } from '../common'
export class UnistylesBridgeWeb {
#timerRef?: ReturnType<typeof setTimeout> = undefined
#windowResizeDebounceTimeMs: number = 100
#hasAdaptiveThemes: boolean = false
#supportsAutomaticColorScheme = false
#screenWidth = isServer ? undefined : window.innerWidth
#screenHeight = isServer ? undefined : window.innerHeight
#themes: Array<keyof UnistylesThemes> = []
#breakpoints: UnistylesBreakpoints = {} as UnistylesBreakpoints
#colorScheme: ColorSchemeName = this.getPreferredColorScheme()
#themeName: keyof UnistylesThemes = '' as keyof UnistylesThemes
#enabledPlugins: Array<string> = [normalizeWebStylesPlugin.name]
#unistylesEvents = new NativeEventEmitter(NativeModules.Unistyles)
#sortedBreakpointPairs: Array<[keyof UnistylesBreakpoints, number]> = []
#breakpoint: keyof UnistylesBreakpoints = '' as keyof UnistylesBreakpoints
#contentSizeCategory: string = 'unspecified'
#insets: ScreenInsets = {
top: 0,
right: 0,
bottom: 0,
left: 0
}
#statusBar: StatusBar = {
height: 0,
width: 0,
setColor: () => {},
setHidden: () => {}
}
#navigationBar: NavigationBar = {
height: 0,
width: 0,
setColor: () => {},
setHidden: () => {}
}
#pixelRatio = 1.0
#fontScale = 1.0
#hairlineWidth = 1
#rtl = false
constructor() {
if (!isServer) {
this.setupListeners()
this.#screenWidth = window.innerWidth
this.#screenHeight = window.innerHeight
this.#rtl = document.documentElement.dir === 'rtl'
}
}
public install() {
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.__UNISTYLES__ = new Proxy({}, {
get: (_target, prop) => {
switch (prop) {
case 'themeName':
return this.getTheme()
case 'screenWidth':
return this.#screenWidth
case 'screenHeight':
return this.#screenHeight
case 'contentSizeCategory':
return this.#contentSizeCategory
case 'breakpoint':
return this.#breakpoint || undefined
case 'breakpoints':
return this.#breakpoints
case 'hasAdaptiveThemes':
return this.#hasAdaptiveThemes
case 'sortedBreakpointPairs':
return this.#sortedBreakpointPairs
case 'enabledPlugins':
return this.#enabledPlugins
case 'colorScheme':
return this.#colorScheme
case 'insets':
return this.#insets
case 'statusBar':
return this.#statusBar
case 'navigationBar':
return this.#navigationBar
case 'pixelRatio':
return this.#pixelRatio
case 'fontScale':
return this.#fontScale
case 'hairlineWidth':
return this.#hairlineWidth
case 'rtl':
return this.#rtl
case 'useTheme':
return (themeName: keyof UnistylesThemes) => this.useTheme(themeName)
case 'updateTheme':
return (themeName: keyof UnistylesThemes) => this.updateTheme(themeName)
case 'useBreakpoints':
return (breakpoints: UnistylesBreakpoints) => this.useBreakpoints(breakpoints)
case 'useAdaptiveThemes':
return (enable: boolean) => this.useAdaptiveThemes(enable)
case 'addPlugin':
return (pluginName: string, notify: boolean) => this.addPlugin(pluginName, notify)
case 'removePlugin':
return (pluginName: string) => this.removePlugin(pluginName)
case 'setRootViewBackgroundColor':
return (color: Color | string) => this.setRootViewBackgroundColor(color)
case 'setImmersiveMode':
return () => {}
case 'setWindowResizeDebounceTimeMs':
return (timeMs: number) => {
if (timeMs >= 0) {
this.#windowResizeDebounceTimeMs = timeMs
}
}
default:
return Reflect.get(this, prop)
}
},
set: (target, prop, newValue, receiver) => {
switch (prop) {
case 'themes': {
this.#themes = newValue
this.#supportsAutomaticColorScheme = newValue.includes('light') && newValue.includes('dark')
return true
}
case 'themeName': {
this.#themeName = newValue as keyof UnistylesThemes
this.emitThemeChange()
return true
}
default:
return Reflect.set(target, prop, newValue, receiver)
}
}
})
return true
}
private useTheme(themeName: keyof UnistylesThemes) {
this.#themeName = themeName
this.emitThemeChange()
}
private updateTheme(themeName: keyof UnistylesThemes) {
if (!this.#themeName) {
this.#themeName = this.getTheme()
}
if (this.#themeName === themeName) {
this.emitThemeChange()
}
}
private useBreakpoints(breakpoints: UnistylesBreakpoints) {
this.#breakpoints = breakpoints
this.#sortedBreakpointPairs = Object
.entries(breakpoints)
.sort(([, a], [, b]) => (a ?? 0) - (b ?? 0)) as Array<[keyof UnistylesBreakpoints, number]>
if (!isServer) {
this.#breakpoint = this.getBreakpointFromScreenWidth(this.#screenWidth as number)
}
}
private useAdaptiveThemes(enable: boolean) {
this.#hasAdaptiveThemes = enable
if (!this.#hasAdaptiveThemes || !this.#supportsAutomaticColorScheme) {
return
}
if (this.#themeName !== this.#colorScheme) {
this.#themeName = this.#colorScheme as keyof UnistylesThemes
this.emitThemeChange()
}
}
private addPlugin(pluginName: string, notify: boolean) {
this.#enabledPlugins = [pluginName].concat(this.#enabledPlugins)
if (notify) {
this.emitPluginChange()
}
}
private removePlugin(pluginName: string) {
this.#enabledPlugins = this.#enabledPlugins.filter(name => name !== pluginName)
this.emitPluginChange()
}
private getTheme(): keyof UnistylesThemes {
if (this.#themes.length === 1) {
return this.#themes.at(0) as keyof UnistylesThemes
}
return this.#themeName
}
private setupListeners() {
const onResize = () => {
this.#screenWidth = window.innerWidth
this.#screenHeight = window.innerHeight
this.#breakpoint = this.getBreakpointFromScreenWidth(this.#screenWidth)
this.emitLayoutChange()
}
window.addEventListener('resize', () => {
if (this.#windowResizeDebounceTimeMs === 0) {
return onResize()
}
clearTimeout(this.#timerRef)
this.#timerRef = setTimeout(onResize, this.#windowResizeDebounceTimeMs)
})
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
this.#colorScheme = event.matches
? 'dark'
: 'light'
if (!this.#supportsAutomaticColorScheme || !this.#hasAdaptiveThemes) {
return
}
if (this.#colorScheme !== this.#themeName) {
this.#themeName = this.#colorScheme as keyof UnistylesThemes
this.emitThemeChange()
}
})
new MutationObserver(() => {
this.#rtl = document.documentElement.dir === 'rtl'
}).observe(document.documentElement, {
attributes: true,
attributeFilter: ['dir']
})
}
private getBreakpointFromScreenWidth(width: number): keyof UnistylesBreakpoints {
const breakpoint = this.#sortedBreakpointPairs
.find(([, value], index, otherBreakpoints) => {
const minVal = value
const maxVal = otherBreakpoints[index + 1]?.[1]
if (!maxVal) {
return true
}
return width >= minVal && width < maxVal
})
return breakpoint?.at(0) as keyof UnistylesBreakpoints
}
private getPreferredColorScheme() {
if (!isServer && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
}
private setRootViewBackgroundColor(color?: Color | string) {
document.body.style.backgroundColor = color as string
}
private emitPluginChange() {
this.#unistylesEvents.emit('__unistylesOnChange', {
type: 'plugin'
})
}
private emitThemeChange() {
this.#unistylesEvents.emit('__unistylesOnChange', {
type: 'theme',
payload: {
themeName: this.#themeName
}
})
}
private emitLayoutChange() {
this.#unistylesEvents.emit('__unistylesOnChange', {
type: 'layout',
payload: {
breakpoint: this.#breakpoint,
orientation: (this.#screenWidth as number) > (this.#screenHeight as number)
? 'landscape'
: 'portrait',
screen: {
width: this.#screenWidth,
height: this.#screenHeight
}
}
})
}
}
export const UnistylesModule = new UnistylesBridgeWeb()