1 contributor
//
// MeterView.swift
// USB Meter
//
// Created by Bogdan Timofte on 04/03/2020.
// Copyright © 2020 Bogdan Timofte. All rights reserved.
//
// MARK: Parent frame https://stackoverflow.com/questions/56832865/how-to-access-parents-frame-in-swiftui
import SwiftUI
import CoreBluetooth
struct MeterView: View {
private struct TabBarStyle {
let showsTitles: Bool
let horizontalPadding: CGFloat
let topPadding: CGFloat
let bottomPadding: CGFloat
let chipHorizontalPadding: CGFloat
let chipVerticalPadding: CGFloat
let outerPadding: CGFloat
let maxWidth: CGFloat
let barBackgroundOpacity: CGFloat
let materialOpacity: CGFloat
let shadowOpacity: CGFloat
let floatingInset: CGFloat
static let portrait = TabBarStyle(
showsTitles: true,
horizontalPadding: 16,
topPadding: 10,
bottomPadding: 8,
chipHorizontalPadding: 10,
chipVerticalPadding: 7,
outerPadding: 6,
maxWidth: 420,
barBackgroundOpacity: 0.10,
materialOpacity: 0.78,
shadowOpacity: 0,
floatingInset: 0
)
static let landscapeInline = TabBarStyle(
showsTitles: true,
horizontalPadding: 12,
topPadding: 10,
bottomPadding: 8,
chipHorizontalPadding: 10,
chipVerticalPadding: 7,
outerPadding: 6,
maxWidth: 420,
barBackgroundOpacity: 0.10,
materialOpacity: 0.78,
shadowOpacity: 0,
floatingInset: 0
)
static let landscapeFloating = TabBarStyle(
showsTitles: false,
horizontalPadding: 16,
topPadding: 10,
bottomPadding: 0,
chipHorizontalPadding: 11,
chipVerticalPadding: 11,
outerPadding: 7,
maxWidth: 260,
barBackgroundOpacity: 0.02,
materialOpacity: 0,
shadowOpacity: 0.18,
floatingInset: 12
)
}
private enum MeterTab: Hashable {
case home
case live
case chart
case settings
var title: String {
switch self {
case .home: return "Home"
case .live: return "Live"
case .chart: return "Chart"
case .settings: return "Settings"
}
}
var systemImage: String {
switch self {
case .home: return "house.fill"
case .live: return "waveform.path.ecg"
case .chart: return "chart.xyaxis.line"
case .settings: return "gearshape.fill"
}
}
}
@EnvironmentObject private var meter: Meter
@Environment(\.dismiss) private var dismiss
private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
#if os(iOS)
private static let isPhone: Bool = UIDevice.current.userInterfaceIdiom == .phone
#else
private static let isPhone: Bool = false
#endif
@State private var selectedMeterTab: MeterTab = .home
@State private var navBarTitle: String = "Meter"
@State private var navBarShowRSSI: Bool = false
@State private var navBarRSSI: Int = 0
@State private var landscapeTabBarHeight: CGFloat = 0
var body: some View {
GeometryReader { proxy in
let landscape = isLandscape(size: proxy.size)
let usesOverlayTabBar = landscape && Self.isPhone
let tabBarStyle = tabBarStyle(for: landscape, usesOverlayTabBar: usesOverlayTabBar)
VStack(spacing: 0) {
if Self.isMacIPadApp {
macNavigationHeader
}
Group {
if landscape {
landscapeDeck(
size: proxy.size,
usesOverlayTabBar: usesOverlayTabBar,
tabBarStyle: tabBarStyle
)
} else {
portraitContent(size: proxy.size, tabBarStyle: tabBarStyle)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
#if !targetEnvironment(macCatalyst)
.navigationBarHidden(Self.isMacIPadApp || landscape)
#endif
}
.background(meterBackground)
.modifier(IOSOnlyNavBar(
apply: !Self.isMacIPadApp,
title: navBarTitle,
showRSSI: navBarShowRSSI,
rssi: navBarRSSI,
meter: meter
))
.onAppear {
navBarTitle = meter.name.isEmpty ? "Meter" : meter.name
navBarShowRSSI = meter.operationalState > .notPresent
navBarRSSI = meter.btSerial.averageRSSI
}
.onChange(of: meter.name) { name in
navBarTitle = name.isEmpty ? "Meter" : name
}
.onChange(of: meter.operationalState) { state in
navBarShowRSSI = state > .notPresent
}
.onChange(of: meter.btSerial.averageRSSI) { newRSSI in
if abs(newRSSI - navBarRSSI) >= 5 {
navBarRSSI = newRSSI
}
}
}
// MARK: - Custom navigation header for Designed-for-iPad on Mac
private var macNavigationHeader: some View {
HStack(spacing: 12) {
Button {
dismiss()
} label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
.font(.body.weight(.semibold))
Text("USB Meters")
}
.foregroundColor(.accentColor)
}
.buttonStyle(.plain)
Text(meter.name.isEmpty ? "Meter" : meter.name)
.font(.headline)
.lineLimit(1)
Spacer()
MeterConnectionToolbarButton(
operationalState: meter.operationalState,
showsTitle: true,
connectAction: { meter.connect() },
disconnectAction: { meter.disconnect() }
)
if meter.operationalState > .notPresent {
RSSIView(RSSI: meter.btSerial.averageRSSI)
.frame(width: 18, height: 18)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea(edges: .top)
)
.overlay(alignment: .bottom) {
Rectangle()
.fill(Color.secondary.opacity(0.12))
.frame(height: 1)
}
}
private func portraitContent(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
portraitSegmentedDeck(size: size, tabBarStyle: tabBarStyle)
}
@ViewBuilder
private func landscapeDeck(size: CGSize, usesOverlayTabBar: Bool, tabBarStyle: TabBarStyle) -> some View {
if usesOverlayTabBar {
landscapeOverlaySegmentedDeck(size: size, tabBarStyle: tabBarStyle)
} else {
landscapeSegmentedDeck(size: size, tabBarStyle: tabBarStyle)
}
}
private func landscapeOverlaySegmentedDeck(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
ZStack(alignment: .top) {
landscapeSegmentedContent(size: size)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, landscapeContentTopPadding(for: tabBarStyle))
.id(selectedMeterTab)
.transition(.opacity.combined(with: .move(edge: .trailing)))
segmentedTabBar(style: tabBarStyle, showsConnectionAction: !Self.isMacIPadApp)
}
.animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
.animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
.onAppear {
normalizeSelectedTab()
}
.onChange(of: availableMeterTabs) { _ in
normalizeSelectedTab()
}
.onPreferenceChange(MeterTabBarHeightPreferenceKey.self) { height in
if height > 0 {
landscapeTabBarHeight = height
}
}
}
private func landscapeSegmentedDeck(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
VStack(spacing: 0) {
segmentedTabBar(style: tabBarStyle, showsConnectionAction: !Self.isMacIPadApp)
landscapeSegmentedContent(size: size)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.id(selectedMeterTab)
.transition(.opacity.combined(with: .move(edge: .trailing)))
}
.animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
.animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
.onAppear {
normalizeSelectedTab()
}
.onChange(of: availableMeterTabs) { _ in
normalizeSelectedTab()
}
}
private func portraitSegmentedDeck(size: CGSize, tabBarStyle: TabBarStyle) -> some View {
VStack(spacing: 0) {
segmentedTabBar(style: tabBarStyle)
portraitSegmentedContent(size: size)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.id(selectedMeterTab)
.transition(.opacity.combined(with: .move(edge: .trailing)))
}
.animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
.animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
.onAppear {
normalizeSelectedTab()
}
.onChange(of: availableMeterTabs) { _ in
normalizeSelectedTab()
}
}
private func segmentedTabBar(style: TabBarStyle, showsConnectionAction: Bool = false) -> some View {
let isFloating = style.floatingInset > 0
let cornerRadius = style.showsTitles ? 14.0 : 22.0
return HStack {
Spacer(minLength: 0)
HStack(spacing: 8) {
ForEach(availableMeterTabs, id: \.self) { tab in
let isSelected = selectedMeterTab == tab
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedMeterTab = tab
}
} label: {
HStack(spacing: 6) {
Image(systemName: tab.systemImage)
.font(.subheadline.weight(.semibold))
if style.showsTitles {
Text(tab.title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
}
}
.foregroundColor(
isSelected
? .white
: (isFloating ? .white.opacity(0.82) : .primary)
)
.padding(.horizontal, style.chipHorizontalPadding)
.padding(.vertical, style.chipVerticalPadding)
.frame(maxWidth: .infinity)
.background(
Capsule()
.fill(
isSelected
? meter.color.opacity(isFloating ? 0.94 : 1)
: (isFloating ? Color.white.opacity(0.045) : Color.secondary.opacity(0.12))
)
)
}
.buttonStyle(.plain)
.accessibilityLabel(tab.title)
}
}
.frame(maxWidth: style.maxWidth)
.padding(style.outerPadding)
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(
isFloating
? LinearGradient(
colors: [
Color.white.opacity(0.14),
Color.white.opacity(0.06)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
: LinearGradient(
colors: [
Color.secondary.opacity(style.barBackgroundOpacity),
Color.secondary.opacity(style.barBackgroundOpacity)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
)
.overlay {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(
isFloating ? Color.white.opacity(0.10) : Color.clear,
lineWidth: 1
)
}
.background {
if !isFloating {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(.ultraThinMaterial)
}
}
.shadow(color: Color.black.opacity(style.shadowOpacity), radius: isFloating ? 28 : 24, x: 0, y: isFloating ? 16 : 12)
Spacer(minLength: 0)
}
.padding(.horizontal, style.horizontalPadding)
.padding(.top, style.topPadding)
.padding(.bottom, style.bottomPadding)
.background(
GeometryReader { geometry in
Color.clear
.preference(key: MeterTabBarHeightPreferenceKey.self, value: geometry.size.height)
}
)
.padding(.horizontal, style.floatingInset)
.background {
if style.floatingInset == 0 {
Rectangle()
.fill(.ultraThinMaterial)
.opacity(style.materialOpacity)
.ignoresSafeArea(edges: .top)
}
}
.overlay(alignment: .bottom) {
if style.floatingInset == 0 {
Rectangle()
.fill(Color.secondary.opacity(0.12))
.frame(height: 1)
}
}
.overlay(alignment: .trailing) {
if showsConnectionAction {
MeterConnectionToolbarButton(
operationalState: meter.operationalState,
showsTitle: false,
connectAction: { meter.connect() },
disconnectAction: { meter.disconnect() }
)
.font(.title3.weight(.semibold))
.padding(.trailing, style.horizontalPadding + style.floatingInset + 4)
.padding(.top, style.topPadding)
.padding(.bottom, style.bottomPadding)
}
}
}
@ViewBuilder
private func landscapeSegmentedContent(size: CGSize) -> some View {
switch selectedMeterTab {
case .home:
MeterHomeTabView(size: size, isLandscape: true)
case .live:
if meter.operationalState == .dataIsAvailable {
MeterLiveTabView(size: size, isLandscape: true)
} else {
MeterHomeTabView(size: size, isLandscape: true)
}
case .chart:
if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
MeterChartTabView(size: size, isLandscape: true)
} else {
MeterHomeTabView(size: size, isLandscape: true)
}
case .settings:
MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
withAnimation(.easeInOut(duration: 0.22)) {
selectedMeterTab = .home
}
}
}
}
@ViewBuilder
private func portraitSegmentedContent(size: CGSize) -> some View {
switch selectedMeterTab {
case .home:
MeterHomeTabView(size: size, isLandscape: false)
case .live:
if meter.operationalState == .dataIsAvailable {
MeterLiveTabView(size: size, isLandscape: false)
} else {
MeterHomeTabView(size: size, isLandscape: false)
}
case .chart:
if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
MeterChartTabView(size: size, isLandscape: false)
} else {
MeterHomeTabView(size: size, isLandscape: false)
}
case .settings:
MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
withAnimation(.easeInOut(duration: 0.22)) {
selectedMeterTab = .home
}
}
}
}
private var availableMeterTabs: [MeterTab] {
var tabs: [MeterTab] = [.home]
if meter.operationalState == .dataIsAvailable {
tabs.append(.live)
if meter.measurements.power.context.isValid {
tabs.append(.chart)
}
}
tabs.append(.settings)
return tabs
}
private func normalizeSelectedTab() {
guard availableMeterTabs.contains(selectedMeterTab) else {
withAnimation(.easeInOut(duration: 0.22)) {
selectedMeterTab = .home
}
return
}
}
private var meterBackground: some View {
LinearGradient(
colors: [
meter.color.opacity(0.22),
Color.secondary.opacity(0.08),
Color.clear
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
}
private func isLandscape(size: CGSize) -> Bool {
size.width > size.height
}
private func tabBarStyle(for landscape: Bool, usesOverlayTabBar: Bool) -> TabBarStyle {
if usesOverlayTabBar {
return .landscapeFloating
}
if landscape {
return .landscapeInline
}
return .portrait
}
private func landscapeContentTopPadding(for style: TabBarStyle) -> CGFloat {
if style.floatingInset > 0 {
return max(landscapeTabBarHeight * 0.44, 26)
}
return max(landscapeTabBarHeight - 6, 0)
}
}
private struct MeterTabBarHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
private struct IOSOnlyNavBar: ViewModifier {
let apply: Bool
let title: String
let showRSSI: Bool
let rssi: Int
let meter: Meter
@ViewBuilder
func body(content: Content) -> some View {
if apply {
content
.navigationBarTitle(title)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
MeterConnectionToolbarButton(
operationalState: meter.operationalState,
showsTitle: false,
connectAction: { meter.connect() },
disconnectAction: { meter.disconnect() }
)
.font(.body.weight(.semibold))
if showRSSI {
RSSIView(RSSI: rssi)
.frame(width: 18, height: 18)
}
}
}
} else {
content
}
}
}