// // 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 portraitCompact = TabBarStyle( showsTitles: false, horizontalPadding: 16, topPadding: 10, bottomPadding: 8, chipHorizontalPadding: 12, chipVerticalPadding: 10, outerPadding: 6, maxWidth: 320, barBackgroundOpacity: 0.14, materialOpacity: 0.90, 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.16, materialOpacity: 0.88, shadowOpacity: 0.12, floatingInset: 12 ) } private enum MeterTab: String, 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, size: proxy.size ) 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 } } .onChange(of: selectedMeterTab) { newTab in meter.preferredTabIdentifier = newTab.rawValue } } // 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(displayedMeterTab) .transition(.opacity.combined(with: .move(edge: .trailing))) segmentedTabBar(style: tabBarStyle, showsConnectionAction: !Self.isMacIPadApp) } .animation(.easeInOut(duration: 0.22), value: displayedMeterTab) .animation(.easeInOut(duration: 0.22), value: availableMeterTabs) .onAppear { restoreSelectedTab() } .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(displayedMeterTab) .transition(.opacity.combined(with: .move(edge: .trailing))) } .animation(.easeInOut(duration: 0.22), value: displayedMeterTab) .animation(.easeInOut(duration: 0.22), value: availableMeterTabs) .onAppear { restoreSelectedTab() } } 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(displayedMeterTab) .transition(.opacity.combined(with: .move(edge: .trailing))) } .animation(.easeInOut(duration: 0.22), value: displayedMeterTab) .animation(.easeInOut(duration: 0.22), value: availableMeterTabs) .onAppear { restoreSelectedTab() } } private func segmentedTabBar(style: TabBarStyle, showsConnectionAction: Bool = false) -> some View { let isFloating = style.floatingInset > 0 let cornerRadius = style.showsTitles ? 14.0 : 22.0 let unselectedForegroundColor = isFloating ? Color.primary.opacity(0.86) : Color.primary let unselectedChipFill = isFloating ? Color.black.opacity(0.08) : Color.secondary.opacity(0.12) return HStack { Spacer(minLength: 0) HStack(spacing: 8) { ForEach(availableMeterTabs, id: \.self) { tab in let isSelected = displayedMeterTab == 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 : unselectedForegroundColor ) .padding(.horizontal, style.chipHorizontalPadding) .padding(.vertical, style.chipVerticalPadding) .frame(maxWidth: .infinity) .background( Capsule() .fill( isSelected ? meter.color.opacity(isFloating ? 0.94 : 1) : unselectedChipFill ) ) } .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.76), Color.white.opacity(0.52) ], 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.black.opacity(0.08) : Color.clear, lineWidth: 1 ) } .background { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(.ultraThinMaterial) .opacity(style.materialOpacity) } .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 displayedMeterTab { case .home: MeterHomeTabView(size: size, isLandscape: true) case .live: MeterLiveTabView(size: size, isLandscape: true) case .chart: MeterChartTabView(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 displayedMeterTab { case .home: MeterHomeTabView(size: size, isLandscape: false) case .live: MeterLiveTabView(size: size, isLandscape: false) case .chart: MeterChartTabView(size: size, isLandscape: false) case .settings: MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) { withAnimation(.easeInOut(duration: 0.22)) { selectedMeterTab = .home } } } } private var availableMeterTabs: [MeterTab] { [.home, .live, .chart, .settings] } private var displayedMeterTab: MeterTab { if availableMeterTabs.contains(selectedMeterTab) { return selectedMeterTab } return .home } private func restoreSelectedTab() { guard let restoredTab = MeterTab(rawValue: meter.preferredTabIdentifier) else { meter.preferredTabIdentifier = MeterTab.home.rawValue selectedMeterTab = .home return } selectedMeterTab = restoredTab } 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, size: CGSize) -> TabBarStyle { if usesOverlayTabBar { return .landscapeFloating } if landscape { return .landscapeInline } if Self.isPhone && size.width < 390 { return .portraitCompact } 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 } } }