diff --git a/Material.xcodeproj/project.pbxproj b/Material.xcodeproj/project.pbxproj index c3d62b1e6..612f9a34a 100644 --- a/Material.xcodeproj/project.pbxproj +++ b/Material.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ 96E3C39A1D3A1CC20086A024 /* ErrorTextField.swift in Headers */ = {isa = PBXBuildFile; fileRef = 961F18E71CD93E3E008927C5 /* ErrorTextField.swift */; settings = {ATTRIBUTES = (Public, ); }; }; 96E3C39C1D3A1CC20086A024 /* Offset.swift in Headers */ = {isa = PBXBuildFile; fileRef = 968C99461D377849000074FF /* Offset.swift */; settings = {ATTRIBUTES = (Public, ); }; }; 96F1A5531F24F17A001D8CAF /* TabsController.swift in Headers */ = {isa = PBXBuildFile; fileRef = 96E09DC71F2287E50000B121 /* TabsController.swift */; settings = {ATTRIBUTES = (Public, ); }; }; + 9D00EBB4216675FB00DBCD69 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D00EBB3216675FB00DBCD69 /* Theme.swift */; }; 9D054A6520D175AC00D0528D /* Material+UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D054A6320D175AC00D0528D /* Material+UIButton.swift */; }; 9D054A6620D175AC00D0528D /* Material+UILabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D054A6420D175AC00D0528D /* Material+UILabel.swift */; }; 9D39A81B20FE8ED100BA8FA1 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D39A81A20FE8ED100BA8FA1 /* ViewController.swift */; }; @@ -292,6 +293,7 @@ 96E09DC71F2287E50000B121 /* TabsController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabsController.swift; sourceTree = ""; }; 96E3C3931D397AE90086A024 /* Material+UIView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Material+UIView.swift"; sourceTree = ""; }; 96F1DC871D654FDF0025F925 /* Material+CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Material+CALayer.swift"; sourceTree = ""; }; + 9D00EBB3216675FB00DBCD69 /* Theme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 9D054A6320D175AC00D0528D /* Material+UIButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Material+UIButton.swift"; sourceTree = ""; }; 9D054A6420D175AC00D0528D /* Material+UILabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Material+UILabel.swift"; sourceTree = ""; }; 9D39A81A20FE8ED100BA8FA1 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; @@ -567,6 +569,7 @@ 963FBF011D6696AB008F8512 /* Tab */, 966ECF2B1CF4C21B00BB0BDF /* Table */, 96090B031D9D709E00709CA6 /* Text */, + 9D00EBB2216675A800DBCD69 /* Theme */, 963FBF001D66964F008F8512 /* Toolbar */, 9626CA951DAB5370003E2611 /* Transition */, 96BCB8061CB40FD000C806FE /* Type */, @@ -760,6 +763,14 @@ name = Animation; sourceTree = ""; }; + 9D00EBB2216675A800DBCD69 /* Theme */ = { + isa = PBXGroup; + children = ( + 9D00EBB3216675FB00DBCD69 /* Theme.swift */, + ); + name = Theme; + sourceTree = ""; + }; 9DE84D6E1FF0250E00586C8B /* ButtonGroup */ = { isa = PBXGroup; children = ( @@ -1002,6 +1013,7 @@ 966C17731F0439F600D3E83C /* Material+MotionAnimation.swift in Sources */, 965E80E51DD4C53300D61E4B /* PulseAnimation.swift in Sources */, 9DE84D721FF0252600586C8B /* RadioButtonGroup.swift in Sources */, + 9D00EBB4216675FB00DBCD69 /* Theme.swift in Sources */, 965E80FE1DD4D59500D61E4B /* ToolbarController.swift in Sources */, 96328B971E05C0BB009A4C90 /* TableView.swift in Sources */, 965E80F81DD4D59500D61E4B /* ImageCard.swift in Sources */, diff --git a/Sources/iOS/BaseIconLayerButton.swift b/Sources/iOS/BaseIconLayerButton.swift index 83c2a1b00..60a4e206e 100644 --- a/Sources/iOS/BaseIconLayerButton.swift +++ b/Sources/iOS/BaseIconLayerButton.swift @@ -20,6 +20,7 @@ open class BaseIconLayerButton: Button { open override var isSelected: Bool { didSet { iconLayer.setSelected(isSelected, animated: false) + updatePulseColor() } } @@ -84,6 +85,7 @@ open class BaseIconLayerButton: Button { open override func prepare() { super.prepare() layer.addSublayer(iconLayer) + iconLayer.prepare() contentHorizontalAlignment = .left // default was .center reloadImage() } @@ -122,7 +124,7 @@ open class BaseIconLayerButton: Button { /// /// This property affects `intrinsicContentSize` and `sizeThatFits(_:)` /// Use `iconEdgeInsets` to set margins. - open var iconSize: CGFloat = 16 { + open var iconSize: CGFloat = 18 { didSet { reloadImage() } @@ -136,12 +138,24 @@ open class BaseIconLayerButton: Button { /// /// You can use `iconSize` and this property, or `titleEdgeInsets` and `contentEdgeInsets` to position /// the icon however you want. - /// For negative values, behavior is undefined. Default is `5.0` for all four margins - open var iconEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) { + /// For negative values, behavior is undefined. Default is `8.0` for all four margins + open var iconEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) { didSet { reloadImage() } } + + open override func apply(theme: Theme) { + super.apply(theme: theme) + + setIconColor(theme.secondary, for: .selected) + setIconColor(theme.onSurface.withAlphaComponent(0.38), for: .normal) + titleColor = theme.onSurface.withAlphaComponent(0.60) + + selectedPulseColor = theme.secondary + normalPulseColor = theme.onSurface + updatePulseColor() + } /// This might be considered as a hackish way, but it's just manipulation @@ -159,6 +173,18 @@ open class BaseIconLayerButton: Button { UIGraphicsEndImageContext() self.image = image } + + /// Pulse color for selected state. + open var selectedPulseColor = Color.white + + /// Pulse color for normal state. + open var normalPulseColor = Color.white +} + +private extension BaseIconLayerButton { + func updatePulseColor() { + pulseColor = isSelected ? selectedPulseColor : normalPulseColor + } } // MARK: - BaseIconLayer @@ -185,16 +211,6 @@ internal class BaseIconLayer: CALayer { } } - override init() { - super.init() - prepare() - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - prepare() - } - func prepare() { normalColor = { normalColor }() // calling didSet selectedColor = { selectedColor }() // calling didSet diff --git a/Sources/iOS/BottomNavigationController.swift b/Sources/iOS/BottomNavigationController.swift index 38d926f92..48eb81318 100644 --- a/Sources/iOS/BottomNavigationController.swift +++ b/Sources/iOS/BottomNavigationController.swift @@ -51,7 +51,7 @@ private class MaterialTabBar: UITabBar { } } -open class BottomNavigationController: UITabBarController { +open class BottomNavigationController: UITabBarController, Themeable { /// A Boolean that controls if the swipe feature is enabled. open var isSwipeEnabled = true { didSet { @@ -168,6 +168,21 @@ open class BottomNavigationController: UITabBarController { prepareTabBar() isSwipeEnabled = true isMotionEnabled = true + applyCurrentTheme() + } + + /** + Applies the given theme. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { + tabBar.tintColor = theme.secondary + tabBar.barTintColor = theme.background + tabBar.dividerColor = theme.onSurface.withAlphaComponent(0.12) + + if #available(iOS 10.0, *) { + tabBar.unselectedItemTintColor = theme.onSurface.withAlphaComponent(0.54) + } } } diff --git a/Sources/iOS/Button.swift b/Sources/iOS/Button.swift index ceb378531..fe0462666 100644 --- a/Sources/iOS/Button.swift +++ b/Sources/iOS/Button.swift @@ -31,7 +31,7 @@ import UIKit import Motion -open class Button: UIButton, Pulseable, PulseableLayer { +open class Button: UIButton, Pulseable, PulseableLayer, Themeable { /** A CAShapeLayer used to manage elements that would be affected by the clipToBounds property of the backing layer. For example, this @@ -195,8 +195,8 @@ open class Button: UIButton, Pulseable, PulseableLayer { */ public init(image: UIImage?, tintColor: UIColor = Color.blue.base) { super.init(frame: .zero) - prepare(with: image, tintColor: tintColor) prepare() + prepare(with: image, tintColor: tintColor) } /** @@ -206,8 +206,8 @@ open class Button: UIButton, Pulseable, PulseableLayer { */ public init(title: String?, titleColor: UIColor = Color.blue.base) { super.init(frame: .zero) - prepare(with: title, titleColor: titleColor) prepare() + prepare(with: title, titleColor: titleColor) } open override func layoutSubviews() { @@ -281,7 +281,14 @@ open class Button: UIButton, Pulseable, PulseableLayer { contentScaleFactor = Screen.scale prepareVisualLayer() preparePulse() + applyCurrentTheme() } + + /** + Applies the given theme. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { } } extension Button { diff --git a/Sources/iOS/CheckButton.swift b/Sources/iOS/CheckButton.swift index 3804b9e80..3033a8d80 100644 --- a/Sources/iOS/CheckButton.swift +++ b/Sources/iOS/CheckButton.swift @@ -31,6 +31,12 @@ open class CheckButton: BaseIconLayerButton { guard !isAnimating else { return } setSelected(!isSelected, animated: true) } + + open override func apply(theme: Theme) { + super.apply(theme: theme) + + checkmarkColor = theme.onSecondary + } } internal class CheckBoxLayer: BaseIconLayer { @@ -86,6 +92,7 @@ internal class CheckBoxLayer: BaseIconLayer { if isSelected { borderLayer.borderWidth = borderLayerNormalBorderWidth } else { + borderLayer.borderWidth = 0 borderLayer.backgroundColor = (isEnabled ? normalColor : disabledColor).cgColor checkMarkLeftLayer.strokeEnd = 1 checkMarkRightLayer.strokeEnd = 1 diff --git a/Sources/iOS/Editor.swift b/Sources/iOS/Editor.swift index a656065a7..e74c29bf7 100644 --- a/Sources/iOS/Editor.swift +++ b/Sources/iOS/Editor.swift @@ -35,7 +35,7 @@ public enum EditorPlaceholderAnimation { case hidden } -open class Editor: View { +open class Editor: View, Themeable { /// Reference to textView. public let textView = TextView() @@ -173,11 +173,14 @@ open class Editor: View { open override func prepare() { super.prepare() + backgroundColor = nil prepareDivider() prepareTextView() preparePlaceholderLabel() prepareDetailLabel() prepareNotificationHandlers() + + applyCurrentTheme() } open override func layoutSubviews() { @@ -187,6 +190,21 @@ open class Editor: View { layoutBottomLabel(label: detailLabel, verticalOffset: detailVerticalOffset) } + /** + Applies the given theme. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { + placeholderActiveColor = theme.secondary + placeholderNormalColor = theme.onSurface.withAlphaComponent(0.38) + + dividerActiveColor = theme.secondary + dividerNormalColor = theme.onSurface.withAlphaComponent(0.12) + + detailColor = theme.onSurface.withAlphaComponent(0.38) + textView.tintColor = theme.secondary + } + @discardableResult open override func becomeFirstResponder() -> Bool { return textView.becomeFirstResponder() @@ -196,15 +214,6 @@ open class Editor: View { open override func resignFirstResponder() -> Bool { return textView.resignFirstResponder() } - - open override var inputAccessoryView: UIView? { - get { - return textView.inputAccessoryView - } - set(value) { - textView.inputAccessoryView = value - } - } } diff --git a/Sources/iOS/ErrorTextField.swift b/Sources/iOS/ErrorTextField.swift index 9d1e749bd..6ae6f208d 100644 --- a/Sources/iOS/ErrorTextField.swift +++ b/Sources/iOS/ErrorTextField.swift @@ -93,4 +93,10 @@ open class ErrorTextField: TextField { super.layoutSubviews() layoutBottomLabel(label: errorLabel, verticalOffset: errorVerticalOffset) } + + open override func apply(theme: Theme) { + super.apply(theme: theme) + + errorColor = theme.error + } } diff --git a/Sources/iOS/FABButton.swift b/Sources/iOS/FABButton.swift index 2650f54f5..b3c0b5cf4 100644 --- a/Sources/iOS/FABButton.swift +++ b/Sources/iOS/FABButton.swift @@ -36,6 +36,14 @@ open class FABButton: Button { depthPreset = .depth1 shapePreset = .circle pulseAnimation = .centerWithBacking - backgroundColor = .white + } + + open override func apply(theme: Theme) { + super.apply(theme: theme) + + backgroundColor = theme.secondary + titleColor = theme.onSecondary + tintColor = theme.onSecondary + pulseColor = theme.onSecondary } } diff --git a/Sources/iOS/FlatButton.swift b/Sources/iOS/FlatButton.swift index 705a19399..daf2891b6 100644 --- a/Sources/iOS/FlatButton.swift +++ b/Sources/iOS/FlatButton.swift @@ -35,4 +35,13 @@ open class FlatButton: Button { super.prepare() cornerRadiusPreset = .cornerRadius1 } + + open override func apply(theme: Theme) { + super.apply(theme: theme) + + backgroundColor = .clear + titleColor = theme.secondary + tintColor = theme.secondary + pulseColor = theme.secondary + } } diff --git a/Sources/iOS/IconButton.swift b/Sources/iOS/IconButton.swift index d4559f68f..2821168c8 100644 --- a/Sources/iOS/IconButton.swift +++ b/Sources/iOS/IconButton.swift @@ -30,9 +30,33 @@ import UIKit +public enum IconButtonThemingStyle { + /// Theming when background content is in background color. + case onBackground + + /// Theming when background content is in primary color. + case onPrimary +} + open class IconButton: Button { + /// A reference to IconButtonThemingStyle. + open var themingStyle = IconButtonThemingStyle.onBackground + open override func prepare() { super.prepare() pulseAnimation = .center } + + open override func apply(theme: Theme) { + super.apply(theme: theme) + + switch themingStyle { + case .onBackground: + tintColor = theme.secondary + pulseColor = theme.secondary + case .onPrimary: + tintColor = theme.onPrimary + pulseColor = theme.onPrimary + } + } } diff --git a/Sources/iOS/Material+UIColor.swift b/Sources/iOS/Material+UIColor.swift index 66a5d81cf..5d300f45d 100644 --- a/Sources/iOS/Material+UIColor.swift +++ b/Sources/iOS/Material+UIColor.swift @@ -62,3 +62,66 @@ public extension UIColor { self.init(argb: (0xff000000 as UInt32) | rgb) } } + +internal extension UIColor { + /// A tuple of the rgba components. + var components: (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) { + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + + getRed(&r, green: &g, blue: &b, alpha: &a) + + return (r, g, b, a) + } + + /** + Blends given coverColor over this color. + - Parameter with coverColor: A UIColor. + - Returns: Resultant color of blending. + */ + func blend(with coverColor: UIColor) -> UIColor { + + /// Blends channels according to https://en.wikipedia.org/wiki/Alpha_compositing (see `over` operator). + func blendChannel(value: CGFloat, bValue: CGFloat, alpha: CGFloat, bAlpha: CGFloat) -> CGFloat { + return ((1 - alpha) * bValue * bAlpha + alpha * value) / (alpha + bAlpha * (1 - alpha)) + } + + let (r, g, b, a) = coverColor.components + let (bR, bG, bB, bA) = components + + let newR = blendChannel(value: r, bValue: bR, alpha: a, bAlpha: bA) + let newG = blendChannel(value: g, bValue: bG, alpha: a, bAlpha: bA) + let newB = blendChannel(value: b, bValue: bB, alpha: a, bAlpha: bA) + let newA = a + bA * (1 - a) + + return UIColor(red: newR, green: newG, blue: newB, alpha: newA) + } + + /** + Adjusts brightness of the color by given value. + - Parameter by value: A CGFloat value. + - Returns: Adjusted color. + */ + func adjustingBrightness(by value: CGFloat) -> UIColor { + var h: CGFloat = 0 + var s: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + + getHue(&h, saturation: &s, brightness: &b, alpha: &a) + + return UIColor(hue: h, saturation: s, brightness: (b + value).clamp(0, 1), alpha: 1) + } + + /// A lighter version of the color. + var lighter: UIColor { + return adjustingBrightness(by: 0.1) + } + + /// A darker version of the color. + var darker: UIColor { + return adjustingBrightness(by: -0.1) + } +} diff --git a/Sources/iOS/NavigationBar.swift b/Sources/iOS/NavigationBar.swift index b457f6eff..f28f32a76 100644 --- a/Sources/iOS/NavigationBar.swift +++ b/Sources/iOS/NavigationBar.swift @@ -30,7 +30,7 @@ import UIKit -open class NavigationBar: UINavigationBar { +open class NavigationBar: UINavigationBar, Themeable { /// Will layout the view. open var willLayout: Bool { return 0 < bounds.width && 0 < bounds.height && nil != superview @@ -168,7 +168,28 @@ open class NavigationBar: UINavigationBar { let image = UIImage() shadowImage = image setBackgroundImage(image, for: .default) - backgroundColor = .white + applyCurrentTheme() + } + + /** + Applies the given theme. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { + backgroundColor = theme.primary + items?.forEach { + apply(theme: theme, to: $0) + } + } + + /** + Applies the given theme to the navigation item. + - Parameter theme: A Theme. + - Parameter to item: A UINavigationItem. + */ + private func apply(theme: Theme, to item: UINavigationItem) { + Theme.apply(theme: theme, to: item.toolbar) + item.toolbar.backgroundColor = .clear } } @@ -182,8 +203,11 @@ internal extension NavigationBar { return } + if isThemingEnabled { + apply(theme: .current, to: item) + } + let toolbar = item.toolbar - toolbar.backgroundColor = .clear toolbar.interimSpace = interimSpace toolbar.contentEdgeInsets = contentEdgeInsets diff --git a/Sources/iOS/RaisedButton.swift b/Sources/iOS/RaisedButton.swift index d588a60aa..ed4200089 100644 --- a/Sources/iOS/RaisedButton.swift +++ b/Sources/iOS/RaisedButton.swift @@ -35,6 +35,14 @@ open class RaisedButton: Button { super.prepare() depthPreset = .depth1 cornerRadiusPreset = .cornerRadius1 - backgroundColor = .white + } + + open override func apply(theme: Theme) { + super.apply(theme: theme) + + backgroundColor = theme.secondary + titleColor = theme.onSecondary + pulseColor = theme.onSecondary + tintColor = theme.onSecondary } } diff --git a/Sources/iOS/StatusBarController.swift b/Sources/iOS/StatusBarController.swift index 08866bf93..d3e3ac1e8 100644 --- a/Sources/iOS/StatusBarController.swift +++ b/Sources/iOS/StatusBarController.swift @@ -120,6 +120,12 @@ open class StatusBarController: TransitionController { super.prepare() prepareStatusBar() } + + open override func apply(theme: Theme) { + super.apply(theme: theme) + + statusBar.backgroundColor = theme.primary.darker + } } fileprivate extension StatusBarController { diff --git a/Sources/iOS/Switch.swift b/Sources/iOS/Switch.swift index e673c0ae3..dcce9ae32 100644 --- a/Sources/iOS/Switch.swift +++ b/Sources/iOS/Switch.swift @@ -30,12 +30,6 @@ import UIKit -@objc(SwitchStyle) -public enum SwitchStyle: Int { - case light - case dark -} - @objc(SwitchState) public enum SwitchState: Int { case on @@ -59,7 +53,7 @@ public protocol SwitchDelegate { func switchDidChangeState(control: Switch, state: SwitchState) } -open class Switch: UIControl { +open class Switch: UIControl, Themeable { /// Will layout the view. open var willLayout: Bool { return 0 < bounds.width && 0 < bounds.height && nil != superview @@ -200,32 +194,6 @@ open class Switch: UIControl { } } - /// Switch style. - open var switchStyle = SwitchStyle.dark { - didSet { - switch switchStyle { - case .light: - buttonOnColor = Color.blue.darken2 - trackOnColor = Color.blue.lighten3 - buttonOffColor = Color.blueGrey.lighten4 - trackOffColor = Color.grey.lighten2 - buttonOnDisabledColor = Color.grey.lighten2 - trackOnDisabledColor = Color.grey.lighten2 - buttonOffDisabledColor = Color.grey.lighten2 - trackOffDisabledColor = Color.grey.lighten2 - case .dark: - buttonOnColor = Color.blue.lighten1 - trackOnColor = Color.blue.lighten2.withAlphaComponent(0.5) - buttonOffColor = Color.grey.lighten2 - trackOffColor = Color.blueGrey.lighten4.withAlphaComponent(0.5) - buttonOnDisabledColor = Color.grey.darken3 - trackOnDisabledColor = Color.grey.lighten1.withAlphaComponent(0.2) - buttonOffDisabledColor = Color.grey.darken3 - trackOffDisabledColor = Color.grey.lighten1.withAlphaComponent(0.2) - } - } - } - /// Switch size. open var switchSize = SwitchSize.medium { didSet { @@ -287,13 +255,12 @@ open class Switch: UIControl { - Parameter style: A SwitchStyle value. - Parameter size: A SwitchSize value. */ - public init(state: SwitchState = .off, style: SwitchStyle = .dark, size: SwitchSize = .medium) { + public init(state: SwitchState = .off, size: SwitchSize = .medium) { track = UIView() button = FABButton() super.init(frame: .zero) prepare() prepareSwitchState(state: state) - prepareSwitchStyle(style: style) prepareSwitchSize(size: size) } @@ -356,8 +323,24 @@ open class Switch: UIControl { prepareTrack() prepareButton() prepareSwitchState() - prepareSwitchStyle() prepareSwitchSize() + applyCurrentTheme() + } + + /** + Applies the given theme. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { + buttonOnColor = theme.secondary + trackOnColor = theme.secondary.withAlphaComponent(0.60) + buttonOffColor = theme.surface.blend(with: theme.onSurface.withAlphaComponent(0.15).blend(with: theme.secondary.withAlphaComponent(0.06))) + trackOffColor = theme.onSurface.withAlphaComponent(0.12) + + buttonOnDisabledColor = theme.surface.blend(with: theme.onSurface.withAlphaComponent(0.15)) + trackOnDisabledColor = theme.onSurface.withAlphaComponent(0.15) + buttonOffDisabledColor = buttonOnDisabledColor + trackOffDisabledColor = trackOnDisabledColor } } @@ -564,15 +547,6 @@ fileprivate extension Switch { updateSwitchState(state: state, animated: false, isTriggeredByUserInteraction: false) } - /** - Prepares the switchStyle property. This is used mainly to allow - init to set the state value and have an effect. - - Parameter style: The SwitchStyle to set. - */ - func prepareSwitchStyle(style: SwitchStyle = .light) { - switchStyle = style - } - /** Prepares the switchSize property. This is used mainly to allow init to set the size value and have an effect. diff --git a/Sources/iOS/TabBar.swift b/Sources/iOS/TabBar.swift index 1346f1a9f..a4b681d8b 100644 --- a/Sources/iOS/TabBar.swift +++ b/Sources/iOS/TabBar.swift @@ -614,6 +614,7 @@ internal extension TabBar { */ func finishLineTransition(isAnimated: Bool = true) { line.motionViewTransition.finish(isAnimated: isAnimated) + line.transition([]) } /** @@ -622,6 +623,7 @@ internal extension TabBar { */ func cancelLineTransition(isAnimated: Bool = true) { line.motionViewTransition.cancel(isAnimated: isAnimated) + line.transition([]) } } diff --git a/Sources/iOS/TabsController.swift b/Sources/iOS/TabsController.swift index 682a4c6cd..0c539f4a0 100644 --- a/Sources/iOS/TabsController.swift +++ b/Sources/iOS/TabsController.swift @@ -39,6 +39,12 @@ public enum TabBarAlignment: Int { case bottom } +public enum TabBarThemingStyle { + case auto + case primary + case secondary +} + extension UIViewController { /// TabItem reference. @objc @@ -161,10 +167,14 @@ open class TabsController: TransitionController { /// The tabBar alignment. open var tabBarAlignment = TabBarAlignment.bottom { didSet { + updateTabBarAlignment() layoutSubviews() } } + /// The tabBar theming style. + open var tabBarThemingStyle = TabBarThemingStyle.auto + /** A UIPanGestureRecognizer property internally used for the interactive swipe. @@ -218,11 +228,55 @@ open class TabsController: TransitionController { prepareTabBar() prepareTabItems() prepareSelectedIndexViewController() + applyCurrentTheme() } open override func transition(to viewController: UIViewController, completion: ((Bool) -> Void)?) { transition(to: viewController, isTriggeredByUserInteraction: false, completion: completion) } + + open override func apply(theme: Theme) { + super.apply(theme: theme) + + switch tabBarThemingStyle { + case .auto where (parent is NavigationController || parent is ToolbarController) && tabBarAlignment == .top: + fallthrough + + case .primary: + applyPrimary(theme: theme) + + default: + applySecondary(theme: theme) + } + } +} + +private extension TabsController { + /** + Applies theming taking primary color as base. + - Parameter theme: A Theme + */ + func applyPrimary(theme: Theme) { + tabBar.lineColor = theme.onPrimary.withAlphaComponent(0.68) + tabBar.backgroundColor = theme.primary + tabBar.setTabItemsColor(theme.onPrimary, for: .normal) + tabBar.setTabItemsColor(theme.onPrimary, for: .selected) + tabBar.setTabItemsColor(theme.onPrimary, for: .highlighted) + tabBar.isDividerHidden = true + } + + /** + Applies theming taking secondary color as base. + - Parameter theme: A Theme + */ + func applySecondary(theme: Theme) { + tabBar.lineColor = theme.secondary + tabBar.backgroundColor = theme.background + tabBar.setTabItemsColor(theme.onSurface.withAlphaComponent(0.60), for: .normal) + tabBar.setTabItemsColor(theme.secondary, for: .selected) + tabBar.setTabItemsColor(theme.secondary, for: .highlighted) + tabBar.dividerColor = theme.onSurface.withAlphaComponent(0.12) + } } fileprivate extension TabsController { @@ -273,9 +327,14 @@ fileprivate extension TabsController { /// Prepares the TabBar. func prepareTabBar() { - tabBar.lineAlignment = .bottom == tabBarAlignment ? .top : .bottom tabBar._delegate = self view.addSubview(tabBar) + updateTabBarAlignment() + } + + func updateTabBarAlignment() { + tabBar.lineAlignment = .bottom == tabBarAlignment ? .top : .bottom + tabBar.dividerAlignment = .bottom == tabBarAlignment ? .top : .bottom } /// Prepares the `tabBar.tabItems`. diff --git a/Sources/iOS/TextField.swift b/Sources/iOS/TextField.swift index 63791edc7..ccb11e06c 100644 --- a/Sources/iOS/TextField.swift +++ b/Sources/iOS/TextField.swift @@ -63,7 +63,7 @@ public protocol TextFieldDelegate: UITextFieldDelegate { optional func textField(textField: TextField, didClear text: String?) } -open class TextField: UITextField { +open class TextField: UITextField, Themeable { /// Minimum TextField text height. private let minimumTextHeight: CGFloat = 32 @@ -459,6 +459,25 @@ open class TextField: UITextField { prepareTargetHandlers() prepareTextAlignment() prepareRightView() + applyCurrentTheme() + } + + /** + Applies the given theme. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { + placeholderActiveColor = theme.secondary + placeholderNormalColor = theme.onSurface.withAlphaComponent(0.38) + + leftViewActiveColor = theme.secondary + leftViewNormalColor = theme.onSurface.withAlphaComponent(0.38) + + dividerActiveColor = theme.secondary + dividerNormalColor = theme.onSurface.withAlphaComponent(0.12) + + detailColor = theme.onSurface.withAlphaComponent(0.38) + textColor = theme.onSurface.withAlphaComponent(0.87) } } diff --git a/Sources/iOS/TextView.swift b/Sources/iOS/TextView.swift index 457c8b577..ecfdbff07 100644 --- a/Sources/iOS/TextView.swift +++ b/Sources/iOS/TextView.swift @@ -87,7 +87,7 @@ public protocol TextViewDelegate : UITextViewDelegate { optional func textView(textView: TextView, didProcessEditing textStorage: TextStorage, text: String, range: NSRange) } -open class TextView: UITextView { +open class TextView: UITextView, Themeable { /// A boolean indicating whether the text is empty. open var isEmpty: Bool { return 0 == text?.utf16.count @@ -289,6 +289,7 @@ open class TextView: UITextView { prepareNotificationHandlers() prepareRegularExpression() preparePlaceholderLabel() + applyCurrentTheme() } open override var contentSize: CGSize { @@ -354,6 +355,15 @@ open class TextView: UITextView { super.paste(sender) fixTypingFont() } + + /** + Applies the given theme. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { + textColor = theme.onSurface.withAlphaComponent(0.87) + placeholderColor = theme.onSurface.withAlphaComponent(0.38) + } } fileprivate extension TextView { diff --git a/Sources/iOS/Theme.swift b/Sources/iOS/Theme.swift new file mode 100644 index 000000000..447152930 --- /dev/null +++ b/Sources/iOS/Theme.swift @@ -0,0 +1,229 @@ +/* + * Copyright (C) 2015 - 2018, Daniel Dahan and CosmicMind, Inc. . + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * * Neither the name of CosmicMind nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import UIKit +import Motion + +public protocol Themeable: class { + /** + Applies given theme. + - Parameter theme: A Theme. + */ + func apply(theme: Theme) + + /// A boolean indicating if theming is enabled. + var isThemingEnabled: Bool { get set } +} + +public struct Theme: Hashable { + /// The color displayed most frequently across the app. + public var primary = Color.blue.darken2 + + /// Accent color for some components such as FABMenu. + public var secondary = Color.blue.base + + /// Background color for view controllers and some components. + public var background = Color.white + + /// Background color for components such as cards, and dialogs. + public var surface = Color.white + + /// Error color for components such as ErrorTextField. + public var error = Color.red.base + + + /// Text and iconography color to be used on primary color. + public var onPrimary = Color.white + + /// Text and iconography color to be used on secondary color. + public var onSecondary = Color.white + + /// Text and iconography color to be used on background color. + public var onBackground = Color.black + + /// Text and iconography color to be used on surface color. + public var onSurface = Color.black + + /// Text and iconography color to be used on error color. + public var onError = Color.white + + /// An initializer. + public init() { } +} + +public extension Theme { + /// Current theme for Material. + static private(set) var current = Theme.light + + /// A light theme. + static var light = Theme() + + /// A dark theme. + static var dark: Theme = { + var t = Theme() + t.primary = UIColor(rgb: 0x202020) + t.secondary = Color.teal.base + t.background = UIColor(rgb: 0x303030) + t.surface = t.background + t.onBackground = .white + t.onSurface = .white + return t + }() +} + +public extension Theme { + /** + Applies theme to the entire app. + - Parameter theme: A Theme. + */ + static func apply(theme: Theme) { + current = theme + guard let v = Application.rootViewController else { + return + } + + apply(theme: theme, to: v) + } + + /** + Applies theme to the hierarchy of given view. + - Parameter theme: A Theme. + - Parameter to view: A UIView. + */ + static func apply(theme: Theme, to view: UIView) { + guard !((view as? Themeable)?.isThemingEnabled == false), !view.isProcessed else { + return + } + + view.subviews.forEach { + apply(theme: theme, to: $0) + } + + (view as? Themeable)?.apply(theme: theme) + } + + /** + Applies theme to the hierarchy of given view controller. + - Parameter theme: A Theme. + - Parameter to viewController: A UIViewController. + */ + static func apply(theme: Theme, to viewController: UIViewController) { + guard !((viewController as? Themeable)?.isThemingEnabled == false) else { + return + } + + viewController.allChildren.forEach { + apply(theme: theme, to: $0) + $0.view.isProcessed = true + } + + apply(theme: theme, to: viewController.view) + + viewController.allChildren.forEach { + $0.view.isProcessed = false + } + + (viewController as? Themeable)?.apply(theme: theme) + } + + /** + Applies provided theme for the components created within the given block + without chaging app's theme. + - Parameter theme: A Theme. + - Parameter for block: A code block. + */ + static func applying(theme: Theme, for execute: () -> Void) { + let v = current + current = theme + execute() + current = v + } +} + + +/// A memory reference to the isThemingEnabled for Themeable NSObject subclasses. +private var IsThemingEnabledKey: UInt8 = 0 + +public extension Themeable where Self: NSObject { + /// A boolean indicating if theming is enabled. + var isThemingEnabled: Bool { + get { + return AssociatedObject.get(base: self, key: &IsThemingEnabledKey) { + true + } + } + set(value) { + AssociatedObject.set(base: self, key: &IsThemingEnabledKey, value: value) + } + } + + /// Applies current theme to itself if theming is enabled. + internal func applyCurrentTheme() { + guard isThemingEnabled else { + return + } + + apply(theme: .current) + } +} + +private extension UIViewController { + /// Returns all possible child view controllers. + var allChildren: [UIViewController] { + var all = children + + if let v = self as? TabsController { + all += v.viewControllers + } + + if let v = presentedViewController, v.presentingViewController === self { + all.append(v) + } + + return all + } +} + +/// A memory reference to the isProcessed for UIView. +private var IsProcessedKey: UInt8 = 0 + +private extension UIView { + /// A boolean indicating if view is already themed. + var isProcessed: Bool { + get { + return AssociatedObject.get(base: self, key: &IsProcessedKey) { + false + } + } + set(value) { + AssociatedObject.set(base: self, key: &IsProcessedKey, value: value) + } + } +} diff --git a/Sources/iOS/Toolbar.swift b/Sources/iOS/Toolbar.swift index 54ec4feff..ed27796cd 100644 --- a/Sources/iOS/Toolbar.swift +++ b/Sources/iOS/Toolbar.swift @@ -30,7 +30,7 @@ import UIKit -open class Toolbar: Bar { +open class Toolbar: Bar, Themeable { /// A convenience property to set the titleLabel.text. @IBInspectable open var title: String? { @@ -63,6 +63,24 @@ open class Toolbar: Bar { @IBInspectable public let detailLabel = UILabel() + open override var leftViews: [UIView] { + didSet { + prepareIconButtons(leftViews) + } + } + + open override var centerViews: [UIView] { + didSet { + prepareIconButtons(centerViews) + } + } + + open override var rightViews: [UIView] { + didSet { + prepareIconButtons(rightViews) + } + } + /** An initializer that initializes the object with a NSCoder object. - Parameter aDecoder: A NSCoder instance. @@ -129,6 +147,29 @@ open class Toolbar: Bar { prepareDetailLabel() } + /** + Applies the given theme. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { + backgroundColor = theme.primary + (leftViews + rightViews + centerViews).forEach { + guard let v = $0 as? IconButton, v.isThemingEnabled else { + return + } + + v.apply(theme: theme) + } + + if !((titleLabel as? Themeable)?.isThemingEnabled == false) { + titleLabel.textColor = theme.onPrimary + } + + if !((detailLabel as? Themeable)?.isThemingEnabled == false) { + detailLabel.textColor = theme.onPrimary + } + } + /// A reference to titleLabel.textAlignment observation. private var titleLabelTextAlignmentObserver: NSKeyValueObservation! } @@ -152,4 +193,12 @@ private extension Toolbar { detailLabel.font = RobotoFont.regular(with: 12) detailLabel.textColor = Color.darkText.secondary } + + func prepareIconButtons(_ views: [UIView]) { + views.forEach { + ($0 as? IconButton)?.themingStyle = .onPrimary + } + + applyCurrentTheme() + } } diff --git a/Sources/iOS/ViewController.swift b/Sources/iOS/ViewController.swift index ea515595b..e8acdf6f3 100644 --- a/Sources/iOS/ViewController.swift +++ b/Sources/iOS/ViewController.swift @@ -30,7 +30,7 @@ import UIKit -open class ViewController: UIViewController { +open class ViewController: UIViewController, Themeable { open override func viewDidLoad() { super.viewDidLoad() prepare() @@ -45,8 +45,8 @@ open class ViewController: UIViewController { */ open func prepare() { view.clipsToBounds = true - view.backgroundColor = .white view.contentScaleFactor = Screen.scale + applyCurrentTheme() } open override func viewWillLayoutSubviews() { @@ -61,4 +61,12 @@ open class ViewController: UIViewController { have a certain need. */ open func layoutSubviews() { } + + /** + Applies given theme to the view controller. + - Parameter theme: A Theme. + */ + open func apply(theme: Theme) { + view.backgroundColor = theme.background + } }