Add `ColorScheme` environment (#136)

You can see the dark scheme environment text representation updated in `EnvironmentDemo`. I suggest adding default dark mode styles in a separate PR, I've created #237 as a reminder for that.
This commit is contained in:
Max Desiatov 2020-08-02 18:55:35 +01:00 committed by GitHub
parent 70d31b2e5b
commit c7b5e75e1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 220 additions and 45 deletions

View File

@ -32,7 +32,10 @@ public protocol App: _TitledApp {
static func _launch(_ app: Self, _ rootEnvironment: EnvironmentValues)
/// Implemented by the renderer to update the `App` on `ScenePhase` changes
var _phasePublisher: CurrentValueSubject<ScenePhase, Never> { get }
var _phasePublisher: AnyPublisher<ScenePhase, Never> { get }
/// Implemented by the renderer to update the `App` on `ColorScheme` changes
var _colorSchemePublisher: AnyPublisher<ColorScheme, Never> { get }
static func main()

View File

@ -48,7 +48,11 @@ public struct _AnyApp: App {
fatalError("`title` cannot be set for `AnyApp`. Access underlying `app` value.")
}
public var _phasePublisher: CurrentValueSubject<ScenePhase, Never> {
public var _phasePublisher: AnyPublisher<ScenePhase, Never> {
fatalError("`_AnyApp` cannot monitor scenePhase. Access underlying `app` value.")
}
public var _colorSchemePublisher: AnyPublisher<ColorScheme, Never> {
fatalError("`_AnyApp` cannot monitor colorScheme. Access underlying `app` value.")
}
}

View File

@ -29,8 +29,8 @@ protocol EnvironmentReader {
case value(Value)
}
var content: Content
let keyPath: KeyPath<EnvironmentValues, Value>
private var content: Content
private let keyPath: KeyPath<EnvironmentValues, Value>
public init(_ keyPath: KeyPath<EnvironmentValues, Value>) {
content = .keyPath(keyPath)
self.keyPath = keyPath

View File

@ -94,6 +94,7 @@ public final class MountedHostView<R: Renderer>: MountedElement<R> {
while let child = mountedChildren.first, let firstChild = childrenViews.first {
let newChild: MountedElement<R>
if firstChild.typeConstructorName == mountedChildren[0].view.typeConstructorName {
child.environmentValues = environmentValues
child.view = firstChild
child.updateEnvironment()
child.update(with: reconciler)

View File

@ -94,12 +94,8 @@ public final class StackReconciler<R: Renderer> {
rootElement.mount(with: self)
if let mountedApp = rootElement as? MountedApp<R> {
app._phasePublisher.sink { [weak self] phase in
if mountedApp.environmentValues.scenePhase != phase {
mountedApp.environmentValues.scenePhase = phase
self?.queueUpdate(for: mountedApp)
}
}.store(in: &mountedApp.subscriptions)
setupSubscription(for: app._phasePublisher, to: \.scenePhase, of: mountedApp)
setupSubscription(for: app._colorSchemePublisher, to: \.colorScheme, of: mountedApp)
}
}
@ -174,6 +170,22 @@ public final class StackReconciler<R: Renderer> {
}.store(in: &compositeElement.subscriptions)
}
private func setupSubscription<T: Equatable>(
for publisher: AnyPublisher<T, Never>,
to keyPath: WritableKeyPath<EnvironmentValues, T>,
of mountedApp: MountedApp<R>
) {
publisher.sink { [weak self, weak mountedApp] value in
guard
let mountedApp = mountedApp,
mountedApp.environmentValues[keyPath: keyPath] != value
else { return }
mountedApp.environmentValues[keyPath: keyPath] = value
self?.queueUpdate(for: mountedApp)
}.store(in: &mountedApp.subscriptions)
}
func render<T>(compositeElement: MountedCompositeElement<R>,
body bodyKeypath: ReferenceWritableKeyPath<MountedCompositeElement<R>, Any>,
result: KeyPath<MountedCompositeElement<R>, (Any) -> T>) -> T {

View File

@ -17,7 +17,7 @@
public struct Color: Hashable, Equatable {
// FIXME: This is not injected.
@Environment(\.accentColor) static var envAccentColor: Color?
@Environment(\.accentColor) static var envAccentColor
public enum RGBColorSpace {
case sRGB

View File

@ -0,0 +1,37 @@
// Copyright 2020 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
public enum ColorScheme: CaseIterable {
case dark
case light
}
public struct _ColorSchemeKey: EnvironmentKey {
public static var defaultValue: ColorScheme {
fatalError("\(self) must have a renderer-provided default value")
}
}
public extension EnvironmentValues {
var colorScheme: ColorScheme {
get { self[_ColorSchemeKey.self] }
set { self[_ColorSchemeKey.self] = newValue }
}
}
public extension View {
func colorScheme(_ colorScheme: ColorScheme) -> some View {
environment(\.colorScheme, colorScheme)
}
}

View File

@ -20,7 +20,7 @@ public struct DisclosureGroup<Label, Content>: View
@State var isExpanded: Bool = false
let isExpandedBinding: Binding<Bool>?
@Environment(\._outlineGroupStyle) var style: _OutlineGroupStyle
@Environment(\._outlineGroupStyle) var style
let label: Label
let content: () -> Content

View File

@ -25,7 +25,7 @@ public struct List<SelectionValue, Content>: View
let selection: _Selection
let content: Content
@Environment(\.listStyle) var style: ListStyle
@Environment(\.listStyle) var style
public init(selection: Binding<Set<SelectionValue>>?, @ViewBuilder content: () -> Content) {
self.selection = .many(selection)

View File

@ -30,7 +30,7 @@ public struct NavigationView<Content>: View where Content: View {
}
/// This is a helper class that works around absence of "package private" access control in Swift
public struct _NavigationViewProxy<Content: View> {
public struct _NavigationViewProxy<Content: View>: View {
public let subject: NavigationView<Content>
public init(_ subject: NavigationView<Content>) { self.subject = subject }

View File

@ -16,7 +16,7 @@ public struct _PickerContainer<Label: View, SelectionValue: Hashable, Content: V
@Binding public var selection: SelectionValue
public let label: Label
public let content: Content
@Environment(\.pickerStyle) public var style: PickerStyle
@Environment(\.pickerStyle) public var style
public init(
selection: Binding<SelectionValue>,
@ -36,7 +36,7 @@ public struct _PickerContainer<Label: View, SelectionValue: Hashable, Content: V
public struct _PickerElement: View {
public let valueIndex: Int?
public let content: AnyView
@Environment(\.pickerStyle) public var style: PickerStyle
@Environment(\.pickerStyle) public var style
public var body: Never {
neverBody("_PickerElement")

View File

@ -39,7 +39,7 @@ public struct TextField<Label>: View where Label: View {
let textBinding: Binding<String>
let onEditingChanged: (Bool) -> ()
let onCommit: () -> ()
@Environment(\.textFieldStyle) var style: TextFieldStyle
@Environment(\.textFieldStyle) var style
public var body: Never {
neverBody("TextField")

View File

@ -18,7 +18,7 @@
public struct Toggle<Label>: View where Label: View {
@Binding var isOn: Bool
var label: Label
@Environment(\.toggleStyle) var toggleStyle: _AnyToggleStyle
@Environment(\.toggleStyle) var toggleStyle
public init(isOn: Binding<Bool>, label: () -> Label) {
_isOn = isOn

View File

@ -20,22 +20,6 @@ import JavaScriptKit
import TokamakCore
import TokamakStaticHTML
private enum ScenePhaseObserver {
static var publisher = CurrentValueSubject<ScenePhase, Never>(.active)
static func observe() {
_ = document.addEventListener!("visibilitychange", JSClosure { _ in
let visibilityState = document.visibilityState.string
if visibilityState == "visible" {
publisher.send(.active)
} else if visibilityState == "hidden" {
publisher.send(.background)
}
return .undefined
})
}
}
extension App {
/// The default implementation of `launch` for a `TokamakDOM` app.
///
@ -60,6 +44,7 @@ extension App {
_ = body.appendChild!(div)
ScenePhaseObserver.observe()
ColorSchemeObserver.observe()
}
public static func _setTitle(_ title: String) {
@ -69,7 +54,11 @@ extension App {
_ = head.appendChild!(titleTag)
}
public var _phasePublisher: CurrentValueSubject<ScenePhase, Never> {
ScenePhaseObserver.publisher
public var _phasePublisher: AnyPublisher<ScenePhase, Never> {
ScenePhaseObserver.publisher.eraseToAnyPublisher()
}
public var _colorSchemePublisher: AnyPublisher<ColorScheme, Never> {
ColorSchemeObserver.publisher.eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,33 @@
// Copyright 2020 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CombineShim
import JavaScriptKit
enum ColorSchemeObserver {
static var publisher = CurrentValueSubject<ColorScheme, Never>(
.init(matchMediaDarkScheme: matchMediaDarkScheme)
)
private static var closure: JSClosure?
static func observe() {
let closure = JSClosure {
publisher.value = .init(matchMediaDarkScheme: $0[0].object!)
return .undefined
}
_ = matchMediaDarkScheme.addEventListener!("change", closure)
Self.closure = closure
}
}

View File

@ -0,0 +1,36 @@
// Copyright 2020 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import CombineShim
import JavaScriptKit
enum ScenePhaseObserver {
static var publisher = CurrentValueSubject<ScenePhase, Never>(.active)
private static var closure: JSClosure?
static func observe() {
let closure = JSClosure { _ in
let visibilityState = document.visibilityState.string
if visibilityState == "visible" {
publisher.send(.active)
} else if visibilityState == "hidden" {
publisher.send(.background)
}
return .undefined
}
_ = document.addEventListener!("visibilitychange", closure)
Self.closure = closure
}
}

View File

@ -51,8 +51,14 @@ public typealias RadioGroupPickerStyle = TokamakCore.RadioGroupPickerStyle
public typealias SegmentedPickerStyle = TokamakCore.SegmentedPickerStyle
public typealias WheelPickerStyle = TokamakCore.WheelPickerStyle
public typealias ToggleStyle = TokamakCore.ToggleStyle
public typealias ToggleStyleConfiguration = TokamakCore.ToggleStyleConfiguration
public typealias ButtonStyle = TokamakCore.ButtonStyle
public typealias ButtonStyleConfiguration = TokamakCore.ButtonStyleConfiguration
public typealias DefaultButtonStyle = TokamakCore.DefaultButtonStyle
public typealias ColorScheme = TokamakCore.ColorScheme
// MARK: Shapes

View File

@ -24,6 +24,7 @@ extension EnvironmentValues {
static var defaultEnvironment: Self {
var environment = EnvironmentValues()
environment[_ToggleStyleKey] = _AnyToggleStyle(DefaultToggleStyle())
environment[_ColorSchemeKey] = .init(matchMediaDarkScheme: matchMediaDarkScheme)
environment._defaultAppStorage = LocalStorage.standard
_DefaultSceneStorageProvider.default = SessionStorage.standard
@ -54,8 +55,11 @@ private extension AnyView {
}
}
let log = JSObjectRef.global.console.object!.log.function!
let document = JSObjectRef.global.document.object!
let global = JSObjectRef.global
let window = global.window.object!
let matchMediaDarkScheme = window.matchMedia!("(prefers-color-scheme: dark)").object!
let log = global.console.object!.log.function!
let document = global.document.object!
let body = document.body.object!
let head = document.head.object!

View File

@ -0,0 +1,21 @@
// Copyright 2020 Tokamak contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import JavaScriptKit
extension ColorScheme {
init(matchMediaDarkScheme: JSObjectRef) {
self = matchMediaDarkScheme.matches.boolean == true ? .dark : .light
}
}

View File

@ -32,13 +32,28 @@ struct EnvironmentObjectDemo: View {
}
}
extension ColorScheme: CustomStringConvertible {
public var description: String {
switch self {
case .dark: return "dark"
case .light: return "light"
@unknown default: return "unknown"
}
}
}
struct EnvironmentDemo: View {
@Environment(\.font) var font: Font?
@Environment(\.colorScheme) var colorScheme
@Environment(\.font) var font
@EnvironmentObject var testEnv: TestEnvironment
var body: some View {
VStack {
Text(font == nil ? "`font` environment not set." : "\(String(describing: font!))")
Text("`colorScheme` is \(colorScheme.description)")
if let font = font {
Text("`font` environment is \(String(describing: font))")
}
Text(testEnv.envTest)
EnvironmentObjectDemo()
}

View File

@ -27,7 +27,11 @@ extension App {
StaticHTMLRenderer.title = title
}
public var _phasePublisher: CurrentValueSubject<ScenePhase, Never> {
CurrentValueSubject<ScenePhase, Never>(.active)
public var _phasePublisher: AnyPublisher<ScenePhase, Never> {
CurrentValueSubject<ScenePhase, Never>(.active).eraseToAnyPublisher()
}
public var _colorSchemePublisher: AnyPublisher<ColorScheme, Never> {
CurrentValueSubject<ColorScheme, Never>(.light).eraseToAnyPublisher()
}
}

View File

@ -17,6 +17,16 @@
import TokamakCore
extension EnvironmentValues {
/// Returns default settings for the static HTML environment
static var defaultEnvironment: Self {
var environment = EnvironmentValues()
environment[_ColorSchemeKey] = .light
return environment
}
}
public final class HTMLTarget: Target {
var html: AnyHTML
var children: [HTMLTarget] = []
@ -80,7 +90,7 @@ public final class StaticHTMLRenderer: Renderer {
reconciler = StackReconciler(
view: view,
target: rootTarget,
environment: EnvironmentValues(),
environment: .defaultEnvironment,
renderer: self,
scheduler: { _ in
fatalError("Stateful apps cannot be created with TokamakStaticHTML")
@ -94,7 +104,7 @@ public final class StaticHTMLRenderer: Renderer {
reconciler = StackReconciler(
app: app,
target: rootTarget,
environment: EnvironmentValues(),
environment: .defaultEnvironment,
renderer: self,
scheduler: { _ in
fatalError("Stateful apps cannot be created with TokamakStaticHTML")

View File

@ -22,7 +22,7 @@ extension NavigationView: ViewDeferredToRenderer {
width: 100%; height: 100%;
""",
]) {
_NavigationViewProxy(self).body
_NavigationViewProxy(self)
})
}
}