Created
May 4, 2020 14:00
-
-
Save myell0w/2a686c4e3a9af1ef9b66846938c3e296 to your computer and use it in GitHub Desktop.
Cross-Platform Helpers for iOS/macOS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// Used to identify traits of the current UI Environment - especially useful for cross-platform code | |
public struct MNCUITraits: Withable { | |
/// Does *not* identify the device type, but the idiom of the layout to apply | |
/// This means that e.g. on iPad the layoutIdiom can be `.phone`, if run in Split Screen or SlideOver | |
public enum LayoutIdiom { | |
case phone(hasRoundCorners: Bool) | |
case pad(hasRoundCorners: Bool) | |
case mac | |
} | |
enum Constants { | |
static let smallestPhoneSize: CGSize = .init(width: 320.0, height: 568.0) | |
static let biggestPhoneSize: CGSize = .init(width: 414.0, height: 896.0) | |
static let smallestPadSize: CGSize = .init(width: 1024.0, height: 768.0) | |
static let biggestPadSize: CGSize = .init(width: 1366.0, height: 1024.0) | |
} | |
// MARK: - Properties | |
public var layoutIdiom: LayoutIdiom | |
public var containerSize: CGSize | |
public var hasRoundCorners: Bool { | |
switch self.layoutIdiom { | |
case .phone(let hasRoundCorners), .pad(let hasRoundCorners): | |
return hasRoundCorners | |
case .mac: | |
return false | |
} | |
} | |
/// iPhone SE, iPad in SlideOver | |
public var isSmallestPhone: Bool { self.layoutIdiom.isPhone && self.containerSize.width <= Constants.smallestPhoneSize.width } | |
/// iPhone XS Max, iPhone XR | |
public var isBiggestPhone: Bool { self.layoutIdiom.isPhone && self.containerSize.isEqualOrRotated(Constants.biggestPhoneSize) } | |
/// < 10.5" iPad | |
public var isSmallestPad: Bool { self.layoutIdiom.isPad && self.containerSize.isEqualOrRotated(Constants.smallestPadSize) } | |
/// 12" iPad | |
public var isBiggestPad: Bool { self.layoutIdiom.isPad && self.containerSize.isEqualOrRotated(Constants.biggestPadSize) } | |
// MARK: - Lifecycle | |
init(layoutIdiom: LayoutIdiom, containerSize: CGSize) { | |
self.layoutIdiom = layoutIdiom | |
self.containerSize = containerSize | |
} | |
/// The ui traits of the current screen | |
@available(iOSApplicationExtension, unavailable) | |
public static var currentScreen: MNCUITraits { | |
#if os(iOS) | |
let hasRoundCorners = UIDevice.current.mn_hasRoundCorners | |
return .init(layoutIdiom: UI_USER_INTERFACE_IDIOM() == .phone ? .phone(hasRoundCorners: hasRoundCorners) : .pad(hasRoundCorners: hasRoundCorners), | |
containerSize: UIScreen.main.bounds.size) | |
#elseif os(macOS) | |
let screen = assertNotNil(NSScreen.main) ?? NSScreen.screens[0] | |
return .init(layoutIdiom: .mac, containerSize: screen.visibleFrame.size) | |
#endif | |
} | |
public static func current(for view: MNView) -> MNCUITraits { | |
#if os(iOS) | |
let hasRoundCorners = view.safeAreaInsets.bottom > 0.0 | |
return .init(layoutIdiom: view.traitCollection.mn_isPhoneLayout ? .phone(hasRoundCorners: hasRoundCorners) : .pad(hasRoundCorners: hasRoundCorners), | |
containerSize: view.bounds.size) | |
#elseif os(macOS) | |
return .init(layoutIdiom: .mac, containerSize: view.bounds.size) | |
#endif | |
} | |
// MARK: - MNCUITraits | |
/// Returns a generic value (e.g. a CGFloat indicating a padding) for the given layout idiom, | |
/// enforces to specify at least a value for phone, pad and mac and allows to override special devices | |
public func platformDependentValue<T>(smallestPhone: T? = nil, | |
phone: T, | |
biggestPhone: T? = nil, | |
smallestPad: T? = nil, | |
pad: T, | |
biggestPad: T? = nil, | |
mac: T) -> T { | |
// default will never be used here… | |
return self.platformDependentValue(default: phone, smallestPhone: smallestPhone, phone: phone, biggestPhone: biggestPhone, smallestPad: smallestPad, pad: pad, biggestPad: biggestPad, mac: mac) | |
} | |
/// Returns a generic value (e.g. a CGFloat indicating a padding) for the given layout idiom. | |
/// It is required to specify a default value, and optional to override values for given environments | |
/// like iPhone SE-sized phones (`smallestPhone`) or 12" iPad (`biggestPad`) | |
public func platformDependentValue<T>(default defaultValue: T, | |
smallestPhone: T? = nil, | |
phone: T? = nil, | |
biggestPhone: T? = nil, | |
smallestPad: T? = nil, | |
pad: T? = nil, | |
biggestPad: T? = nil, | |
mac: T? = nil) -> T { | |
let overriddenValue: T? | |
switch self.layoutIdiom { | |
case .phone: | |
overriddenValue = self.isSmallestPhone ? (smallestPhone ?? phone) : self.isBiggestPhone ? (biggestPhone ?? phone) : phone | |
case .pad: | |
overriddenValue = self.isSmallestPad ? (smallestPad ?? pad) : self.isBiggestPad ? (biggestPad ?? pad) : pad | |
case .mac: | |
overriddenValue = mac | |
} | |
return overriddenValue ?? defaultValue | |
} | |
} | |
// MARK: - Platform-Dependent Values | |
/// Returns a generic value (e.g. a CGFloat indicating a padding) for the Operating System | |
public func platformDependentValue<T>(iOS: T, macOS: T) -> T { | |
#if os(iOS) | |
return iOS | |
#elseif os(macOS) | |
return macOS | |
#endif | |
} | |
/// Returns a generic value (e.g. a CGFloat indicating a padding) for the given device, | |
/// enforces to specify at least a value for phone, pad and mac and allows to override special devices | |
public func platformDependentValue<T>(smallestPhone: T? = nil, | |
phone: T, | |
biggestPhone: T? = nil, | |
smallestPad: T? = nil, | |
pad: T, | |
biggestPad: T? = nil, | |
mac: T) -> T { | |
// default will never be used here… | |
return platformDependentValue(default: phone, | |
smallestPhone: smallestPhone, | |
phone: phone, | |
biggestPhone: biggestPhone, | |
smallestPad: smallestPad, | |
pad: pad, | |
biggestPad: biggestPad, mac: mac) | |
} | |
/// Returns a generic value (e.g. a CGFloat indicating a padding) for the given device. | |
/// It is required to specify a default value, and optional to override values for given environments | |
/// like iPhone SE-sized phones (`smallestPhone`) or 12" iPad (`biggestPad`) | |
public func platformDependentValue<T>(default defaultValue: T, | |
smallestPhone: T? = nil, | |
phone: T? = nil, | |
biggestPhone: T? = nil, | |
smallestPad: T? = nil, | |
pad: T? = nil, | |
biggestPad: T? = nil, | |
mac: T? = nil) -> T { | |
let overriddenValue: T? | |
#if os(iOS) | |
let device = UIDevice.current | |
if device.userInterfaceIdiom == .phone { | |
overriddenValue = device.mn_isSmallestPhone ? (smallestPhone ?? phone) : device.mn_isBiggestPhone ? (biggestPhone ?? phone) : phone | |
} else { | |
overriddenValue = device.mn_isSmallestPad ? (smallestPad ?? pad) : device.mn_isBiggestPad ? (biggestPad ?? pad) : pad | |
} | |
#elseif os(macOS) | |
overriddenValue = mac | |
#endif | |
return overriddenValue ?? defaultValue | |
} | |
// MARK: - MNView+MNCUITraits | |
public extension MNView { | |
var mn_uiTraits: MNCUITraits { .current(for: self) } // swiftlint:disable:this identifier_name | |
} | |
// MARK: - UIDevice+Detection | |
/// Extension for easier detection of certain devices | |
public extension UIDevice { | |
@available(iOSApplicationExtension, unavailable) | |
@objc var mn_hasRoundCorners: Bool { | |
guard let window = UIApplication.shared.keyWindow else { return false } | |
return window.safeAreaInsets.bottom > 0.0 | |
} | |
/// iPhone with round corners | |
@available(iOSApplicationExtension, unavailable) | |
@objc var mn_hasTopNotch: Bool { | |
guard let window = UIApplication.shared.keyWindow else { return false } | |
guard self.userInterfaceIdiom == .phone else { return false } | |
return window.safeAreaInsets.bottom > 0.0 | |
} | |
/// iPhone SE-sized | |
@objc var mn_isSmallestPhone: Bool { | |
guard self.userInterfaceIdiom == .phone else { return false } | |
let screenSize = UIScreen.main.fixedCoordinateSpace.bounds.size | |
return screenSize.width <= MNCUITraits.Constants.smallestPhoneSize.width | |
} | |
/// iPhone XS Max, iPhone XR | |
@objc var mn_isBiggestPhone: Bool { | |
guard self.userInterfaceIdiom == .phone else { return false } | |
let screenSize = UIScreen.main.fixedCoordinateSpace.bounds.size | |
return screenSize == MNCUITraits.Constants.biggestPhoneSize | |
} | |
/// < 10.5" iPad | |
@objc var mn_isSmallestPad: Bool { | |
guard self.userInterfaceIdiom == .pad else { return false } | |
let screenSize = UIScreen.main.fixedCoordinateSpace.bounds.size | |
return screenSize == MNCUITraits.Constants.smallestPadSize | |
} | |
/// 12" iPad | |
@objc var mn_isBiggestPad: Bool { | |
guard self.userInterfaceIdiom == .pad else { return false } | |
let screenSize = UIScreen.main.fixedCoordinateSpace.bounds.size | |
return screenSize == MNCUITraits.Constants.biggestPadSize | |
} | |
} | |
// MARK: - UITraitCollection+Detection | |
/// Extension for easier detection of certain traits | |
public extension UITraitCollection { | |
@objc var mn_isPadLayout: Bool { self.userInterfaceIdiom == .pad && self.horizontalSizeClass == .regular } | |
@objc var mn_isPhoneLayout: Bool { self.userInterfaceIdiom == .phone || self.horizontalSizeClass == .compact } | |
@objc var mn_isPortraitPhoneLayout: Bool { self.horizontalSizeClass == .compact && self.verticalSizeClass == .regular } | |
@objc var mn_isLandscapePhone: Bool { self.userInterfaceIdiom == .phone && self.verticalSizeClass == .compact } | |
@objc var mn_wantsVerticalCellLayout: Bool { self.preferredContentSizeCategory.isAccessibilityCategory } | |
} | |
// MARK: - Private | |
private extension MNCUITraits.LayoutIdiom { | |
var isPhone: Bool { | |
switch self { | |
case .phone: | |
return true | |
case .pad, .mac: | |
return false | |
} | |
} | |
var isPad: Bool { | |
switch self { | |
case .pad: | |
return true | |
case .phone, .mac: | |
return false | |
} | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment