4*16 widget pool grid for button macro

This commit is contained in:
True Zhuanjia committed 2026-05-21 16:05:57 +08:00
1 parent 2c2088e4b1
commit bb44b2067e
1 file changed
+213 -114
+213 -114
View File
@@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
import Combine
@available(iOS 13.0, *) @available(iOS 13.0, *)
private extension View { private extension View {
@@ -449,7 +450,7 @@ struct WidgetPickerView: View {
case error case error
} }
private let maxPoolSlots = 16 private let maxPoolSlots = 64
private let buttonMacroMinimumDuration: Double = 30 private let buttonMacroMinimumDuration: Double = 30
private let buttonMacroTimedMinimumDuration: Double = 50 private let buttonMacroTimedMinimumDuration: Double = 50
private let buttonMacroMaximumDuration: Double = 2000 private let buttonMacroMaximumDuration: Double = 2000
@@ -505,6 +506,8 @@ struct WidgetPickerView: View {
@SwiftUI.State private var triggerIntervalManualValueText: String = "" @SwiftUI.State private var triggerIntervalManualValueText: String = ""
@SwiftUI.State private var suppressKeyboardDrivenLayoutAnimation = false @SwiftUI.State private var suppressKeyboardDrivenLayoutAnimation = false
@SwiftUI.State private var poolGridInteractionResetToken: Int = 0 @SwiftUI.State private var poolGridInteractionResetToken: Int = 0
@SwiftUI.State private var poolAutoScrollTargetItemID: UUID? = nil
@SwiftUI.State private var poolAutoScrollRequestToken: Int = 0
private static func restoredTab(from availableTabs: [WidgetPickerTab]) -> WidgetPickerTab { private static func restoredTab(from availableTabs: [WidgetPickerTab]) -> WidgetPickerTab {
if let persistedIdentifier = UserDefaults.standard.string(forKey: lastSelectedTabDefaultsKey), if let persistedIdentifier = UserDefaults.standard.string(forKey: lastSelectedTabDefaultsKey),
@@ -1156,6 +1159,7 @@ struct WidgetPickerView: View {
let tabFontSize = isPadPortrait ? 12.0 : metrics.tabFontSize let tabFontSize = isPadPortrait ? 12.0 : metrics.tabFontSize
let tabHorizontalPadding = isPadPortrait ? 10.0 : metrics.tabHorizontalPadding let tabHorizontalPadding = isPadPortrait ? 10.0 : metrics.tabHorizontalPadding
let tabHeight = isPadPortrait ? 30.0 : metrics.tabHeight let tabHeight = isPadPortrait ? 30.0 : metrics.tabHeight
let tabMinWidth = isPadPortrait ? 88.0 : (metrics.isPhone ? 43 : 50)
return HStack(spacing: tabSpacing) { return HStack(spacing: tabSpacing) {
ForEach(availableTabs) { tab in ForEach(availableTabs) { tab in
@@ -1166,6 +1170,7 @@ struct WidgetPickerView: View {
.font(.system(size: tabFontSize, weight: .bold, design: .rounded)) .font(.system(size: tabFontSize, weight: .bold, design: .rounded))
.foregroundColor(selectedTab == tab ? Color.white : Color.black.opacity(0.56)) .foregroundColor(selectedTab == tab ? Color.white : Color.black.opacity(0.56))
.padding(.horizontal, tabHorizontalPadding) .padding(.horizontal, tabHorizontalPadding)
.frame(minWidth: tabMinWidth)
.frame(height: tabHeight) .frame(height: tabHeight)
.background( .background(
Capsule() Capsule()
@@ -1231,7 +1236,9 @@ struct WidgetPickerView: View {
items: poolItems, items: poolItems,
onItemTap: { handlePoolItemTap($0) }, onItemTap: { handlePoolItemTap($0) },
spacing: metrics.poolGridSpacing, spacing: metrics.poolGridSpacing,
contentInset: metrics.poolGridInset contentInset: metrics.poolGridInset,
scrollTargetItemID: poolAutoScrollTargetItemID,
scrollRequestToken: poolAutoScrollRequestToken
) )
.id(poolGridInteractionResetToken) .id(poolGridInteractionResetToken)
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
@@ -1349,6 +1356,7 @@ struct WidgetPickerView: View {
.font(.system(size: customFontSize ?? metrics.tipFontSize, weight: .semibold, design: .rounded)) .font(.system(size: customFontSize ?? metrics.tipFontSize, weight: .semibold, design: .rounded))
.foregroundColor(tipMessageColor) .foregroundColor(tipMessageColor)
.lineLimit(2) .lineLimit(2)
.minimumScaleFactor(0.5)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Spacer() Spacer()
} }
@@ -1423,6 +1431,8 @@ struct WidgetPickerView: View {
rebalanceTriggerIntervalItems() rebalanceTriggerIntervalItems()
} }
normalizeLeadingFunctionalItemPriorityIfNeeded() normalizeLeadingFunctionalItemPriorityIfNeeded()
poolAutoScrollTargetItemID = item.id
poolAutoScrollRequestToken += 1
updateTipMessageForCurrentPoolState() updateTipMessageForCurrentPoolState()
} }
@@ -1832,8 +1842,8 @@ struct WidgetPickerView: View {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
DispatchQueue.main.async { DispatchQueue.main.async {
showButtonMacroSheet = false showButtonMacroSheet = false
editingButtonMacroItemID = nil // editingButtonMacroItemID = nil
poolGridInteractionResetToken += 1 // poolGridInteractionResetToken += 1
NSLog("WidgetPickerView dismissButtonMacroSheet resetTokenAfter=%d", poolGridInteractionResetToken) NSLog("WidgetPickerView dismissButtonMacroSheet resetTokenAfter=%d", poolGridInteractionResetToken)
} }
} }
@@ -2034,8 +2044,8 @@ struct WidgetPickerView: View {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
DispatchQueue.main.async { DispatchQueue.main.async {
showTriggerIntervalSheet = false showTriggerIntervalSheet = false
editingTriggerIntervalItemID = nil // editingTriggerIntervalItemID = nil
poolGridInteractionResetToken += 1 // poolGridInteractionResetToken += 1
} }
} }
@@ -2515,6 +2525,106 @@ struct WidgetPickerView: View {
// .widgetPickerIgnoreKeyboardSafeAreaWhenAvailable() // .widgetPickerIgnoreKeyboardSafeAreaWhenAvailable()
} }
private var triggerIntervalSheet: some View {
let isPhone = UIDevice.current.userInterfaceIdiom == .phone
let contentSpacing: CGFloat = isPhone ? 10 : 16
let sectionSpacing: CGFloat = isPhone ? 7 : 10
let fieldHeight: CGFloat = isPhone ? 30 : 38
let segmentedControlHeight: CGFloat = isPhone ? 36 : 38
let actionSpacing: CGFloat = isPhone ? 8 : 12
let actionHeight: CGFloat = isPhone ? 34 : 44
let actionFontSize: CGFloat = isPhone ? 13 : 15
let cardPadding: CGFloat = isPhone ? 12 : 18
let cardMaxWidth: CGFloat = isPhone ? 320 : 420
let cardCornerRadius: CGFloat = isPhone ? 22 : 28
let horizontalInset: CGFloat = isPhone ? 14 : 24
return ZStack {
Color.black.opacity(0.28)
.edgesIgnoringSafeArea(.all)
VStack(alignment: .leading, spacing: contentSpacing) {
VStack(alignment: .leading, spacing: sectionSpacing) {
createInteractiveControlSection(
title: "",
controlHeight: segmentedControlHeight
) {
Picker("", selection: triggerIntervalEditModeBinding) {
ForEach(TriggerIntervalEditMode.allCases) { mode in
Text(mode.title).tag(mode.rawValue)
}
}
.sheetSegmentedControlStyle(height: segmentedControlHeight)
}
.zIndex(30)
createInteractiveControlSection(
title: triggerIntervalTitleText,
controlHeight: fieldHeight
) {
Slider(
value: $triggerIntervalEditorValue,
in: triggerIntervalMinimumDuration...triggerIntervalMaximumDuration,
step: 1
)
.environment(\.colorScheme, .light)
.frame(height: fieldHeight)
.disabled(manualTriggerIntervalValue != nil)
.opacity(manualTriggerIntervalValue == nil ? 1.0 : 0.45)
}
.zIndex(20)
createWidgetSection(title: LocalizationHelper.localizedString(forKey: "Manually enter trigger interval in milliseconds")) {
InteractiveTextField(
placeholder: "",
text: $triggerIntervalManualValueText
)
.frame(height: fieldHeight)
}
.zIndex(40)
}
HStack(spacing: actionSpacing) {
PoolActionChip(
title: LocalizationHelper.localizedString(forKey: "Cancel"),
isPrimary: false,
height: actionHeight,
fontSize: actionFontSize,
cornerRadius: 14
) {
dismissTriggerIntervalSheet()
}
PoolActionChip(
title: LocalizationHelper.localizedString(forKey: "Confirm"),
isPrimary: true,
height: actionHeight,
fontSize: actionFontSize,
cornerRadius: 14
) {
applyTriggerIntervalChanges()
}
}
.padding(.top, isPhone ? 2 : 0)
.zIndex(0)
}
.padding(cardPadding)
.frame(maxWidth: cardMaxWidth)
.background(
RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous)
.fill(Color(red: 0.94, green: 0.97, blue: 0.98).opacity(0.98))
.overlay(
RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.96), lineWidth: 1.1)
)
)
.shadow(color: Color.black.opacity(0.12), radius: 24, x: 0, y: 12)
.padding(.horizontal, horizontalInset)
}
// .widgetPickerIgnoreKeyboardSafeAreaWhenAvailable()
}
/* /*
private var buttonMacroSheet: some View { private var buttonMacroSheet: some View {
let isPhone = UIDevice.current.userInterfaceIdiom == .phone let isPhone = UIDevice.current.userInterfaceIdiom == .phone
@@ -2618,104 +2728,6 @@ struct WidgetPickerView: View {
} }
*/ */
private var triggerIntervalSheet: some View {
let isPhone = UIDevice.current.userInterfaceIdiom == .phone
let contentSpacing: CGFloat = isPhone ? 10 : 16
let sectionSpacing: CGFloat = isPhone ? 7 : 10
let fieldHeight: CGFloat = isPhone ? 30 : 38
let segmentedControlHeight: CGFloat = isPhone ? 36 : 38
let actionSpacing: CGFloat = isPhone ? 8 : 12
let actionHeight: CGFloat = isPhone ? 34 : 44
let actionFontSize: CGFloat = isPhone ? 13 : 15
let cardPadding: CGFloat = isPhone ? 12 : 18
let cardMaxWidth: CGFloat = isPhone ? 320 : 420
let cardCornerRadius: CGFloat = isPhone ? 22 : 28
let horizontalInset: CGFloat = isPhone ? 14 : 24
return ZStack {
Color.black.opacity(0.28)
.edgesIgnoringSafeArea(.all)
VStack(alignment: .leading, spacing: contentSpacing) {
VStack(alignment: .leading, spacing: sectionSpacing) {
createInteractiveControlSection(
title: "",
controlHeight: segmentedControlHeight
) {
Picker("", selection: triggerIntervalEditModeBinding) {
ForEach(TriggerIntervalEditMode.allCases) { mode in
Text(mode.title).tag(mode.rawValue)
}
}
.sheetSegmentedControlStyle(height: segmentedControlHeight)
}
.zIndex(30)
createInteractiveControlSection(
title: triggerIntervalTitleText,
controlHeight: fieldHeight
) {
Slider(
value: $triggerIntervalEditorValue,
in: triggerIntervalMinimumDuration...triggerIntervalMaximumDuration,
step: 1
)
.environment(\.colorScheme, .light)
.frame(height: fieldHeight)
.disabled(manualTriggerIntervalValue != nil)
.opacity(manualTriggerIntervalValue == nil ? 1.0 : 0.45)
}
.zIndex(20)
createWidgetSection(title: LocalizationHelper.localizedString(forKey: "Trigger interval")) {
InteractiveTextField(
placeholder: LocalizationHelper.localizedString(forKey: "Manually enter trigger interval in milliseconds"),
text: $triggerIntervalManualValueText
)
.frame(height: fieldHeight)
}
.zIndex(40)
}
HStack(spacing: actionSpacing) {
PoolActionChip(
title: LocalizationHelper.localizedString(forKey: "Cancel"),
isPrimary: false,
height: actionHeight,
fontSize: actionFontSize,
cornerRadius: 14
) {
dismissTriggerIntervalSheet()
}
PoolActionChip(
title: LocalizationHelper.localizedString(forKey: "Confirm"),
isPrimary: true,
height: actionHeight,
fontSize: actionFontSize,
cornerRadius: 14
) {
applyTriggerIntervalChanges()
}
}
.padding(.top, isPhone ? 2 : 0)
.zIndex(0)
}
.padding(cardPadding)
.frame(maxWidth: cardMaxWidth)
.background(
RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous)
.fill(Color(red: 0.94, green: 0.97, blue: 0.98).opacity(0.98))
.overlay(
RoundedRectangle(cornerRadius: cardCornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.96), lineWidth: 1.1)
)
)
.shadow(color: Color.black.opacity(0.12), radius: 24, x: 0, y: 12)
.padding(.horizontal, horizontalInset)
}
// .widgetPickerIgnoreKeyboardSafeAreaWhenAvailable()
}
/* /*
private func createInteractiveControlContainer<Content: View>( private func createInteractiveControlContainer<Content: View>(
@@ -3826,8 +3838,12 @@ struct WidgetPoolGridView: View {
let onItemTap: (WidgetPoolItem) -> Void let onItemTap: (WidgetPoolItem) -> Void
let spacing: CGFloat let spacing: CGFloat
let contentInset: CGFloat let contentInset: CGFloat
let scrollTargetItemID: UUID?
let scrollRequestToken: Int
private let columns = 4 private let columns = 4
private let visibleRows = 4
@SwiftUI.State private var lastHandledScrollRequestToken: Int = -1
var body: some View { var body: some View {
GeometryReader { proxy in GeometryReader { proxy in
@@ -3835,12 +3851,16 @@ struct WidgetPoolGridView: View {
let height = proxy.size.height let height = proxy.size.height
let availableSide = max(min(width, height) - contentInset * 2, 1) let availableSide = max(min(width, height) - contentInset * 2, 1)
let cellSize = (availableSide - CGFloat(columns - 1) * spacing) / CGFloat(columns) let cellSize = (availableSide - CGFloat(columns - 1) * spacing) / CGFloat(columns)
let gridSide = cellSize * CGFloat(columns) + spacing * CGFloat(columns - 1)
let gridOriginX = (width - gridSide) * 0.5
let gridOriginY = (height - gridSide) * 0.5
let placements = makePlacements(for: items, columns: columns) let placements = makePlacements(for: items, columns: columns)
let totalRows = max(visibleRows, (placements.map(\.row).max() ?? -1) + 1)
let gridWidth = cellSize * CGFloat(columns) + spacing * CGFloat(columns - 1)
let viewportGridHeight = cellSize * CGFloat(visibleRows) + spacing * CGFloat(visibleRows - 1)
let contentGridHeight = cellSize * CGFloat(totalRows) + spacing * CGFloat(max(totalRows - 1, 0))
let viewportHeight = viewportGridHeight + contentInset * 2
let contentHeight = contentGridHeight + contentInset * 2
let gridOriginX = (width - gridWidth) * 0.5
ZStack(alignment: .topLeading) { ZStack {
RoundedRectangle(cornerRadius: 24, style: .continuous) RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill( .fill(
LinearGradient( LinearGradient(
@@ -3861,8 +3881,86 @@ struct WidgetPoolGridView: View {
NSLog("WidgetPickerView pool grid background tapped") NSLog("WidgetPickerView pool grid background tapped")
} }
Group {
if #available(iOS 14.0, *) {
poolScrollViewWithAutoScroll(
width: width,
cellSize: cellSize,
contentHeight: contentHeight,
contentInset: contentInset,
totalRows: totalRows,
gridOriginX: gridOriginX,
placements: placements,
showsIndicators: totalRows > visibleRows
)
} else {
ScrollView(.vertical, showsIndicators: totalRows > visibleRows) {
poolGridContent(
width: width,
cellSize: cellSize,
contentHeight: contentHeight,
contentInset: contentInset,
totalRows: totalRows,
gridOriginX: gridOriginX,
placements: placements
)
}
}
}
.frame(height: viewportHeight)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
.frame(height: viewportHeight)
}
}
@available(iOS 14.0, *)
private func poolScrollViewWithAutoScroll(
width: CGFloat,
cellSize: CGFloat,
contentHeight: CGFloat,
contentInset: CGFloat,
totalRows: Int,
gridOriginX: CGFloat,
placements: [WidgetPoolPlacement],
showsIndicators: Bool
) -> some View {
ScrollViewReader { scrollProxy in
ScrollView(.vertical, showsIndicators: showsIndicators) {
poolGridContent(
width: width,
cellSize: cellSize,
contentHeight: contentHeight,
contentInset: contentInset,
totalRows: totalRows,
gridOriginX: gridOriginX,
placements: placements
)
}
.onReceive(Just(scrollRequestToken)) { requestToken in
guard requestToken != lastHandledScrollRequestToken else { return }
lastHandledScrollRequestToken = requestToken
guard let scrollTargetItemID else { return }
DispatchQueue.main.async {
scrollProxy.scrollTo(scrollTargetItemID, anchor: .bottom)
}
}
}
}
@ViewBuilder
private func poolGridContent(
width: CGFloat,
cellSize: CGFloat,
contentHeight: CGFloat,
contentInset: CGFloat,
totalRows: Int,
gridOriginX: CGFloat,
placements: [WidgetPoolPlacement]
) -> some View {
ZStack(alignment: .topLeading) {
ForEach(0..<columns, id: \.self) { column in ForEach(0..<columns, id: \.self) { column in
ForEach(0..<columns, id: \.self) { row in ForEach(0..<totalRows, id: \.self) { row in
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.white.opacity(0.16)) .fill(Color.white.opacity(0.16))
.overlay( .overlay(
@@ -3872,7 +3970,7 @@ struct WidgetPoolGridView: View {
.frame(width: cellSize, height: cellSize) .frame(width: cellSize, height: cellSize)
.position( .position(
x: gridOriginX + CGFloat(column) * (cellSize + spacing) + cellSize * 0.5, x: gridOriginX + CGFloat(column) * (cellSize + spacing) + cellSize * 0.5,
y: gridOriginY + CGFloat(row) * (cellSize + spacing) + cellSize * 0.5 y: contentInset + CGFloat(row) * (cellSize + spacing) + cellSize * 0.5
) )
.allowsHitTesting(false) .allowsHitTesting(false)
} }
@@ -3885,6 +3983,7 @@ struct WidgetPoolGridView: View {
height: cellSize height: cellSize
) )
.contentShape(Rectangle()) .contentShape(Rectangle())
.id(placement.item.id)
.onTapGesture { .onTapGesture {
NSLog( NSLog(
"WidgetPickerView pool item tapped cmd=%@ id=%@ row=%d column=%d span=%d", "WidgetPickerView pool item tapped cmd=%@ id=%@ row=%d column=%d span=%d",
@@ -3899,12 +3998,12 @@ struct WidgetPoolGridView: View {
.position( .position(
x: gridOriginX + CGFloat(placement.column) * (cellSize + spacing) x: gridOriginX + CGFloat(placement.column) * (cellSize + spacing)
+ (cellSize * CGFloat(placement.item.span) + spacing * CGFloat(placement.item.span - 1)) * 0.5, + (cellSize * CGFloat(placement.item.span) + spacing * CGFloat(placement.item.span - 1)) * 0.5,
y: gridOriginY + CGFloat(placement.row) * (cellSize + spacing) + cellSize * 0.5 y: contentInset + CGFloat(placement.row) * (cellSize + spacing) + cellSize * 0.5
) )
.zIndex(1) .zIndex(1)
} }
} }
} .frame(width: width, height: contentHeight, alignment: .topLeading)
} }
private func poolItemView(for item: WidgetPoolItem, cellSize: CGFloat) -> some View { private func poolItemView(for item: WidgetPoolItem, cellSize: CGFloat) -> some View {
@@ -3929,7 +4028,7 @@ struct WidgetPoolGridView: View {
Text(item.displayCmd) Text(item.displayCmd)
.font(.system(size: max(11, cellSize * 0.18), weight: .bold, design: .rounded)) .font(.system(size: max(11, cellSize * 0.18), weight: .bold, design: .rounded))
.foregroundColor(Color.black.opacity(0.70)) .foregroundColor(Color.black.opacity(0.70))
.minimumScaleFactor(0.6) .minimumScaleFactor(0.25)
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 6) .padding(.horizontal, 6)
@@ -3955,7 +4054,7 @@ struct WidgetPoolGridView: View {
Text(item.displayCmd) Text(item.displayCmd)
.font(.system(size: max(11, cellSize * 0.18), weight: .bold, design: .rounded)) .font(.system(size: max(11, cellSize * 0.18), weight: .bold, design: .rounded))
.foregroundColor(Color.black.opacity(0.70)) .foregroundColor(Color.black.opacity(0.70))
.minimumScaleFactor(0.6) .minimumScaleFactor(0.25)
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 8) .padding(.horizontal, 8)