iOS/Sources/Extensions/Widgets/Common/WidgetBasicContainerView.swift

241 lines
8.3 KiB
Swift

import AppIntents
import Shared
import SwiftUI
import WidgetKit
struct WidgetBasicContainerView: View {
@Environment(\.widgetFamily) var family: WidgetFamily
let emptyViewGenerator: () -> AnyView
let contents: [WidgetBasicViewModel]
let type: WidgetType
init(emptyViewGenerator: @escaping () -> AnyView, contents: [WidgetBasicViewModel], type: WidgetType) {
self.emptyViewGenerator = emptyViewGenerator
self.contents = contents
self.type = type
// Use the opportunity of widget refresh to also refresh control center controls
// since those controls dont have a refresh interval
DataWidgetsUpdater.updateControlCenterControls()
}
var body: some View {
Group {
if contents.isEmpty {
emptyViewGenerator()
} else {
content(for: contents)
}
}
// Whenever Apple allow apps to use material backgrounds we should update this
.widgetBackground(Color.asset(Asset.Colors.primaryBackground))
}
@available(iOS 16.4, *)
private func intent(for model: WidgetBasicViewModel) -> (any AppIntent)? {
switch model.interactionType {
case .widgetURL:
return nil
case let .appIntent(widgetIntentType):
switch widgetIntentType {
case .action:
let intent = PerformAction()
intent.action = IntentActionAppEntity(id: model.id, displayString: model.title)
intent.hapticConfirmation = true
return intent
case let .script(id, entityId, serverId, name, showConfirmationNotification):
let intent = ScriptAppIntent()
intent.script = .init(
id: id,
entityId: entityId,
serverId: serverId,
serverName: "", // not used in this context
displayString: name,
iconName: "" // not used in this context
)
intent.hapticConfirmation = true
intent.showConfirmationNotification = showConfirmationNotification
return intent
case .refresh:
return ReloadWidgetsAppIntent()
}
}
}
@ViewBuilder
func content(for models: [WidgetBasicViewModel]) -> some View {
let actionCount = models.count
let columnCount = Self.columnCount(family: family, modelCount: actionCount)
let rows = Array(columnify(count: columnCount, models: models))
let sizeStyle: WidgetBasicSizeStyle = {
if models.count == 1 {
return .single
}
let compactBp = Self.compactSizeBreakpoint(for: family)
let condensed = compactBp < actionCount
let compactRowCount = compactBp / Self.columnCount(family: family, modelCount: compactBp)
if condensed {
return .condensed
} else if rows.count < compactRowCount {
return .expanded
} else {
return .regular
}
}()
basicView(rows: rows, sizeStyle: sizeStyle)
}
private func basicView(rows: [[WidgetBasicViewModel]], sizeStyle: WidgetBasicSizeStyle) -> some View {
VStack(alignment: .leading, spacing: Spaces.one) {
ForEach(rows, id: \.self) { column in
HStack(spacing: Spaces.one) {
ForEach(column) { model in
if case let .widgetURL(url) = model.interactionType {
Link(destination: url.withWidgetAuthenticity()) {
if #available(iOS 18.0, *) {
tintedWrapperView(model: model, sizeStyle: sizeStyle)
} else {
normalView(model: model, sizeStyle: sizeStyle)
}
}
} else {
if #available(iOS 17.0, *), let intent = intent(for: model) {
Button(intent: intent) {
tintedWrapperView(model: model, sizeStyle: sizeStyle)
}
.buttonStyle(.plain)
}
}
}
}
}
}
.padding(sizeStyle == .single ? 0 : Spaces.one)
}
private func normalView(model: WidgetBasicViewModel, sizeStyle: WidgetBasicSizeStyle) -> some View {
switch type {
case .button:
return AnyView(WidgetBasicButtonView(
model: model,
sizeStyle: sizeStyle,
tinted: false
))
case .sensor:
return AnyView(WidgetBasicSensorView(
model: model,
sizeStyle: sizeStyle,
tinted: false
))
}
}
@available(iOS 16.0, *)
private func tintedWrapperView(model: WidgetBasicViewModel, sizeStyle: WidgetBasicSizeStyle) -> some View {
switch type {
case .button:
return AnyView(WidgetBasicViewTintedWrapper(
model: model,
sizeStyle: sizeStyle,
viewType: WidgetBasicButtonView.self
))
case .sensor:
return AnyView(WidgetBasicViewTintedWrapper(
model: model,
sizeStyle: sizeStyle,
viewType: WidgetBasicSensorView.self
))
}
}
private func columnify(count: Int, models: [WidgetBasicViewModel]) -> AnyIterator<[WidgetBasicViewModel]> {
var perActionIterator = models.makeIterator()
return AnyIterator { () -> [WidgetBasicViewModel]? in
let column = stride(from: 0, to: count, by: 1)
.compactMap { _ in perActionIterator.next() }
return column.isEmpty == false ? column : nil
}
}
static func columnCount(family: WidgetFamily, modelCount: Int) -> Int {
switch family {
#if !targetEnvironment(macCatalyst) // no ventura SDK yet
case .accessoryCircular, .accessoryInline, .accessoryRectangular: return 1
#endif
case .systemSmall: return 1
case .systemMedium: return 2
case .systemLarge:
if modelCount <= 2 {
// 2 'landscape' actions looks better than 2 'portrait'
return 1
} else {
return 2
}
case .systemExtraLarge:
if modelCount <= 4 {
return 1
} else if modelCount <= 15 {
// note this is 15 and not 16 - divisibility by 3 here
return 3
} else {
return 4
}
@unknown default: return 2
}
}
/// More than this number: show compact (icon left, text right) version
static func compactSizeBreakpoint(for family: WidgetFamily) -> Int {
switch family {
#if !targetEnvironment(macCatalyst) // no ventura SDK yet
case .accessoryCircular,
.accessoryInline,
.accessoryRectangular:
return 1
#endif
case .systemSmall: return 2
case .systemMedium: return 4
case .systemLarge: return 10
case .systemExtraLarge: return 20
@unknown default: return 8
}
}
static func maximumCount(family: WidgetFamily) -> Int {
switch family {
#if !targetEnvironment(macCatalyst) // no ventura SDK yet
case .accessoryCircular,
.accessoryInline,
.accessoryRectangular:
return 1
#endif
case .systemSmall: return 2
case .systemMedium: return 4
case .systemLarge: return 10
case .systemExtraLarge: return 20
@unknown default: return 4
}
}
// This is all widgets that are on the lock screen
// Lock screen widgets are transparent and don't need a colored background
private static var transparentFamilies: [WidgetFamily] {
if #available(iOS 16.0, *) {
[.accessoryCircular, .accessoryRectangular]
} else {
[]
}
}
enum WidgetType: String {
case button
case sensor
}
}