From 8e840c18bd301aa08758eaeeb38c8bfd9f2eb5f2 Mon Sep 17 00:00:00 2001
From: Ariver <ar@MacBook-Air.local>
Date: Sun, 10 May 2026 23:27:26 +0800
Subject: [PATCH] Add grid container display modes
---
Apptag/ApptagApp.swift | 4
Apptag/Localization/en.json | 6 +
Apptag/Localization/zh-Hant.json | 6 +
Apptag/Localization/ja.json | 6 +
Apptag/Info.plist | 2
Apptag/Localization/fr.json | 6 +
CHANGELOG.md | 7 +
Apptag/Localization/ko.json | 6 +
Apptag/Localization/zh-Hans.json | 6 +
Apptag/ContentView.swift | 154 +++++++++++++++++++++++++++++++++++++
Apptag/Localization/it.json | 6 +
Apptag/Localization/es.json | 6 +
Apptag/Localization/ru.json | 6 +
13 files changed, 198 insertions(+), 23 deletions(-)
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index ed8885a..685804e 100644
--- a/Apptag/ApptagApp.swift
+++ b/Apptag/ApptagApp.swift
@@ -590,9 +590,11 @@
Text(tr("settings.flat")).tag("flat")
Text(tr("settings.container")).tag("container")
Text(tr("settings.coloredContainer")).tag("coloredContainer")
+ Text(tr("settings.gridContainer")).tag("gridContainer")
+ Text(tr("settings.coloredGridContainer")).tag("coloredGridContainer")
}
.pickerStyle(.segmented)
- .frame(width: 360, alignment: .leading)
+ .frame(width: 520, alignment: .leading)
Text(tr("settings.flatDesc"))
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
diff --git a/Apptag/ContentView.swift b/Apptag/ContentView.swift
index d5ab10b..14a9452 100644
--- a/Apptag/ContentView.swift
+++ b/Apptag/ContentView.swift
@@ -220,6 +220,14 @@
tagPosition == "left" || tagPosition == "right"
}
+ private var isColorlessContainerMode: Bool {
+ displayMode == "container" || displayMode == "gridContainer"
+ }
+
+ private var isColoredContainerMode: Bool {
+ displayMode == "coloredContainer" || displayMode == "coloredGridContainer"
+ }
+
var body: some View {
ZStack {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
@@ -339,7 +347,7 @@
ForEach(tagLabels) { tag in
TagPill(name: tag.name, colorIndex: tag.colorIndex,
action: {
- if displayMode == "container" {
+ if isColorlessContainerMode {
toggleColorlessFill(tag.id)
}
scrollTo(tag.id)
@@ -361,7 +369,7 @@
ForEach(tagLabels) { tag in
SideTagPill(name: tag.name, colorIndex: tag.colorIndex,
action: {
- if displayMode == "container" {
+ if isColorlessContainerMode {
toggleColorlessFill(tag.id)
}
scrollTo(tag.id)
@@ -387,6 +395,8 @@
Spacer()
ProgressView().scaleEffect(0.8)
Spacer()
+ } else if displayMode == "gridContainer" || displayMode == "coloredGridContainer" {
+ gridContainerGrid
} else if displayMode == "container" || displayMode == "coloredContainer" {
containerGrid
} else {
@@ -469,7 +479,7 @@
private func masonryCard(_ group: TagGroup, width: CGFloat) -> some View {
let isColored = displayMode == "coloredContainer"
- let isColorless = displayMode == "container"
+ let isColorless = isColorlessContainerMode
let isColorlessFilled = isColorless && filledColorlessContainer == group.name
let isHovered = hoveredContainer == group.name
let tagColor = Color(nsColor: TagColor.nsColor(for: tagColors[group.name] ?? 0))
@@ -531,6 +541,144 @@
}
}
+ private var gridContainerGrid: some View {
+ GeometryReader { geo in
+ let outerPad: CGFloat = 20
+ let gap: CGFloat = 16
+ let available = geo.size.width - outerPad * 2
+ let preferredCount = preferredGridContainersPerRow(availableWidth: available)
+ let rows = gridContainerRows(groups: groups, preferredCount: preferredCount)
+
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: gap) {
+ ForEach(rows.indices, id: \.self) { rowIndex in
+ let row = rows[rowIndex]
+ let rowCount = max(1, row.count)
+ let cardWidth = (available - gap * CGFloat(rowCount - 1)) / CGFloat(rowCount)
+ let fixedRows = row.map {
+ iconRows(appCount: $0.apps.count, width: cardWidth)
+ }.max() ?? 1
+
+ HStack(alignment: .top, spacing: gap) {
+ ForEach(row) { group in
+ gridContainerCard(group, width: cardWidth, fixedRows: fixedRows)
+ .id(group.id)
+ }
+ }
+ }
+ }
+ .padding(outerPad)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+ .id(displayMode)
+ .onAppear { scrollProxy = proxy }
+ }
+ }
+ }
+
+ private func preferredGridContainersPerRow(availableWidth: CGFloat) -> Int {
+ let minCardWidth = max(260, iconSize * 3 + 88)
+ if availableWidth >= minCardWidth * 3 + 32 { return 3 }
+ if availableWidth >= minCardWidth * 2 + 16 { return 2 }
+ return 1
+ }
+
+ private func gridContainerRows(groups: [TagGroup], preferredCount: Int) -> [[TagGroup]] {
+ var rows: [[TagGroup]] = []
+ var index = 0
+ while index < groups.count {
+ let remaining = groups.count - index
+ let count = min(max(1, preferredCount), remaining)
+ rows.append(Array(groups[index..<index + count]))
+ index += count
+ }
+ return rows
+ }
+
+ private func iconColumns(width: CGFloat) -> Int {
+ let inner = width - 32
+ let itemW = iconSize + 34
+ return max(1, Int((inner + 6) / itemW))
+ }
+
+ private func iconRows(appCount: Int, width: CGFloat) -> Int {
+ let cols = iconColumns(width: width)
+ return max(1, (appCount + cols - 1) / cols)
+ }
+
+ private func gridContainerCard(_ group: TagGroup, width: CGFloat, fixedRows: Int) -> some View {
+ let isColored = displayMode == "coloredGridContainer"
+ let isColorless = isColorlessContainerMode
+ let isColorlessFilled = isColorless && filledColorlessContainer == group.name
+ let isHovered = hoveredContainer == group.name
+ let cols = iconColumns(width: width)
+ let maxCells = max(cols, fixedRows * cols)
+ let emptyCells = max(0, maxCells - group.apps.count)
+ let tagColor = Color(nsColor: TagColor.nsColor(for: tagColors[group.name] ?? 0))
+
+ return VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 0) {
+ Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
+ .layoutPriority(0)
+ Text(group.name)
+ .font(.system(size: tagFontSize, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .padding(.horizontal, 10)
+ .layoutPriority(1)
+ Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1)
+ .layoutPriority(0)
+ }
+
+ LazyVGrid(
+ columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: cols),
+ spacing: 2
+ ) {
+ ForEach(group.apps) { app in
+ AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) })
+ }
+ ForEach(0..<emptyCells, id: \.self) { _ in
+ Color.clear
+ .frame(width: iconSize + 20, height: iconSize + (hideAppNames ? 16 : 42))
+ }
+ }
+ }
+ .frame(width: width)
+ .padding(16)
+ .background(
+ RoundedRectangle(cornerRadius: 14)
+ .fill((isColored || isColorlessFilled) ? tagColor.opacity(0.30) : Color.clear)
+ .background(
+ RoundedRectangle(cornerRadius: 14)
+ .fill(.ultraThinMaterial)
+ )
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 14)
+ .stroke(Color.primary.opacity(0.08), lineWidth: 1)
+ )
+ .shadow(color: .black.opacity(isColored && isHovered ? 0.22 : 0),
+ radius: isColored && isHovered ? 18 : 0,
+ y: isColored && isHovered ? 10 : 0)
+ .scaleEffect(isColored && isHovered ? 1.015 : 1.0)
+ .animation(.spring(response: 0.25, dampingFraction: 0.82), value: isHovered)
+ .onHover { hovering in
+ if isColored {
+ hoveredContainer = hovering ? group.name : nil
+ } else if hovering {
+ fillColorlessContainer(group.name)
+ }
+ }
+ .contentShape(RoundedRectangle(cornerRadius: 14))
+ .onTapGesture {
+ if isColorlessFilled {
+ filledColorlessContainer = nil
+ }
+ }
+ }
+
// MARK: - Edit Tags View
private var editTagsView: some View {
diff --git a/Apptag/Info.plist b/Apptag/Info.plist
index 55581ed..2d6d866 100644
--- a/Apptag/Info.plist
+++ b/Apptag/Info.plist
@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>3.2.1</string>
+ <string>4.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
diff --git a/Apptag/Localization/en.json b/Apptag/Localization/en.json
index 5da6be7..3755216 100644
--- a/Apptag/Localization/en.json
+++ b/Apptag/Localization/en.json
@@ -13,7 +13,7 @@
"settings.appListStyle": "App list style:",
"settings.flat": "Flat",
"settings.container": "Colorless Container",
- "settings.flatDesc": "\"Flat\" shows apps directly. \"Colorless Container\" wraps each tag group in a rounded box. \"Colored Container\" fills each container with its tag color.",
+ "settings.flatDesc": "\"Flat\" shows apps directly. \"Colorless Container\" and \"Colored Container\" use masonry containers. \"Colorless Grid Container\" and \"Colored Grid Container\" align app icons in equal-height grid rows.",
"settings.hideAppNames": "Hide app names",
"settings.tagPosition": "Tag position:",
"settings.left": "Left",
@@ -56,5 +56,7 @@
"tag.entertainment": "Entertainment",
"tag.system": "System",
"tag.productivity": "Productivity",
- "settings.coloredContainer": "Colored Container"
+ "settings.coloredContainer": "Colored Container",
+ "settings.gridContainer": "Colorless Grid Container",
+ "settings.coloredGridContainer": "Colored Grid Container"
}
diff --git a/Apptag/Localization/es.json b/Apptag/Localization/es.json
index edabdf9..0de0c8a 100644
--- a/Apptag/Localization/es.json
+++ b/Apptag/Localization/es.json
@@ -13,7 +13,7 @@
"settings.appListStyle": "Estilo de lista de apps:",
"settings.flat": "Plano",
"settings.container": "Contenedor sin color",
- "settings.flatDesc": "\"Plano\" muestra las apps directamente. \"Contenedor sin color\" coloca cada grupo en un contenedor sin relleno. \"Contenedor coloreado\" rellena cada contenedor con el color de la etiqueta.",
+ "settings.flatDesc": "\"Plano\" muestra las apps directamente. \"Contenedor sin color\" y \"Contenedor coloreado\" usan contenedores en cascada. \"Contenedor de cuadrícula sin color\" y \"Contenedor de cuadrícula coloreado\" alinean los iconos en filas de cuadrícula de igual altura.",
"settings.hideAppNames": "Ocultar nombres de apps",
"settings.tagPosition": "Posición de etiquetas:",
"settings.left": "Izquierda",
@@ -56,5 +56,7 @@
"tag.entertainment": "Entretenimiento",
"tag.system": "Sistema",
"tag.productivity": "Oficina",
- "settings.coloredContainer": "Contenedor coloreado"
+ "settings.coloredContainer": "Contenedor coloreado",
+ "settings.gridContainer": "Contenedor de cuadrícula sin color",
+ "settings.coloredGridContainer": "Contenedor de cuadrícula coloreado"
}
diff --git a/Apptag/Localization/fr.json b/Apptag/Localization/fr.json
index be13377..2b60867 100644
--- a/Apptag/Localization/fr.json
+++ b/Apptag/Localization/fr.json
@@ -13,7 +13,7 @@
"settings.appListStyle": "Style de liste d'applications :",
"settings.flat": "Liste",
"settings.container": "Conteneur sans couleur",
- "settings.flatDesc": "\"Liste\" affiche les apps directement. \"Conteneur sans couleur\" place chaque groupe dans un conteneur sans remplissage. \"Conteneur coloré\" remplit chaque conteneur avec la couleur du tag.",
+ "settings.flatDesc": "\"Liste\" affiche les apps directement. \"Conteneur sans couleur\" et \"Conteneur coloré\" utilisent des conteneurs en cascade. \"Conteneur grille sans couleur\" et \"Conteneur grille coloré\" alignent les icônes dans des lignes de grille de hauteur égale.",
"settings.hideAppNames": "Masquer les noms d'apps",
"settings.tagPosition": "Position des étiquettes :",
"settings.left": "Gauche",
@@ -56,5 +56,7 @@
"tag.entertainment": "Divertissement",
"tag.system": "Système",
"tag.productivity": "Bureau",
- "settings.coloredContainer": "Conteneur coloré"
+ "settings.coloredContainer": "Conteneur coloré",
+ "settings.gridContainer": "Conteneur grille sans couleur",
+ "settings.coloredGridContainer": "Conteneur grille coloré"
}
diff --git a/Apptag/Localization/it.json b/Apptag/Localization/it.json
index 19e45a3..6aa41b6 100644
--- a/Apptag/Localization/it.json
+++ b/Apptag/Localization/it.json
@@ -13,7 +13,7 @@
"settings.appListStyle": "Stile elenco app:",
"settings.flat": "Elenco",
"settings.container": "Contenitore senza colore",
- "settings.flatDesc": "\"Elenco\" mostra le app direttamente. \"Contenitore senza colore\" inserisce ogni gruppo in un contenitore senza riempimento. \"Contenitore colorato\" riempie ogni contenitore con il colore del tag.",
+ "settings.flatDesc": "\"Elenco\" mostra le app direttamente. \"Contenitore senza colore\" e \"Contenitore colorato\" usano contenitori a cascata. \"Contenitore griglia senza colore\" e \"Contenitore griglia colorato\" allineano le icone in righe griglia di uguale altezza.",
"settings.hideAppNames": "Nascondi nomi app",
"settings.tagPosition": "Posizione tag:",
"settings.left": "Sinistra",
@@ -56,5 +56,7 @@
"tag.entertainment": "Intrattenimento",
"tag.system": "Sistema",
"tag.productivity": "Ufficio",
- "settings.coloredContainer": "Contenitore colorato"
+ "settings.coloredContainer": "Contenitore colorato",
+ "settings.gridContainer": "Contenitore griglia senza colore",
+ "settings.coloredGridContainer": "Contenitore griglia colorato"
}
diff --git a/Apptag/Localization/ja.json b/Apptag/Localization/ja.json
index 454194a..1dde2f2 100644
--- a/Apptag/Localization/ja.json
+++ b/Apptag/Localization/ja.json
@@ -13,7 +13,7 @@
"settings.appListStyle": "アプリ一覧スタイル:",
"settings.flat": "フラット",
"settings.container": "無色コンテナ",
- "settings.flatDesc": "\"フラット\"はアプリを直接表示します。\"無色コンテナ\"は各タググループを塗りなしのコンテナで囲みます。\"カラーコンテナ\"は各コンテナをタグ色で塗ります。",
+ "settings.flatDesc": "\"フラット\"はアプリを直接表示します。\"無色コンテナ\"と\"カラーコンテナ\"はコンテナを滝状に配置します。\"無色グリッドコンテナ\"と\"カラーグリッドコンテナ\"は等高のグリッド行でアプリアイコンを揃えます。",
"settings.hideAppNames": "アプリ名を非表示",
"settings.tagPosition": "タグ位置:",
"settings.left": "左",
@@ -56,5 +56,7 @@
"tag.entertainment": "エンタメ",
"tag.system": "システム",
"tag.productivity": "オフィス",
- "settings.coloredContainer": "カラーコンテナ"
+ "settings.coloredContainer": "カラーコンテナ",
+ "settings.gridContainer": "無色グリッドコンテナ",
+ "settings.coloredGridContainer": "カラーグリッドコンテナ"
}
diff --git a/Apptag/Localization/ko.json b/Apptag/Localization/ko.json
index f7f851e..46419f9 100644
--- a/Apptag/Localization/ko.json
+++ b/Apptag/Localization/ko.json
@@ -13,7 +13,7 @@
"settings.appListStyle": "앱 목록 스타일:",
"settings.flat": "플랫",
"settings.container": "무색 컨테이너",
- "settings.flatDesc": "\"플랫\"은 앱을 직접 표시합니다. \"무색 컨테이너\"는 각 태그 그룹을 채움 없는 컨테이너로 묶습니다. \"색상 컨테이너\"는 각 컨테이너를 태그 색으로 채웁니다.",
+ "settings.flatDesc": "\"플랫\"은 앱을 직접 표시합니다. \"무색 컨테이너\"와 \"색상 컨테이너\"는 컨테이너를 폭포식으로 배치합니다. \"무색 그리드 컨테이너\"와 \"색상 그리드 컨테이너\"는 같은 높이의 그리드 행으로 앱 아이콘을 정렬합니다.",
"settings.hideAppNames": "앱 이름 숨기기",
"settings.tagPosition": "태그 위치:",
"settings.left": "왼쪽",
@@ -56,5 +56,7 @@
"tag.entertainment": "엔터테인먼트",
"tag.system": "시스템",
"tag.productivity": "오피스",
- "settings.coloredContainer": "색상 컨테이너"
+ "settings.coloredContainer": "색상 컨테이너",
+ "settings.gridContainer": "무색 그리드 컨테이너",
+ "settings.coloredGridContainer": "색상 그리드 컨테이너"
}
diff --git a/Apptag/Localization/ru.json b/Apptag/Localization/ru.json
index d9bbf09..5e9b2a1 100644
--- a/Apptag/Localization/ru.json
+++ b/Apptag/Localization/ru.json
@@ -13,7 +13,7 @@
"settings.appListStyle": "Стиль списка приложений:",
"settings.flat": "Плоский",
"settings.container": "Бесцветный контейнер",
- "settings.flatDesc": "\"Плоский\" показывает приложения напрямую. \"Бесцветный контейнер\" помещает группы тегов в контейнеры без заливки. \"Цветной контейнер\" заливает каждый контейнер цветом тега.",
+ "settings.flatDesc": "\"Плоский\" показывает приложения напрямую. \"Бесцветный контейнер\" и \"Цветной контейнер\" используют каскадные контейнеры. \"Бесцветный сеточный контейнер\" и \"Цветной сеточный контейнер\" выравнивают значки приложений в строках сетки одинаковой высоты.",
"settings.hideAppNames": "Скрыть названия приложений",
"settings.tagPosition": "Положение тегов:",
"settings.left": "Слева",
@@ -56,5 +56,7 @@
"tag.entertainment": "Развлечения",
"tag.system": "Система",
"tag.productivity": "Офис",
- "settings.coloredContainer": "Цветной контейнер"
+ "settings.coloredContainer": "Цветной контейнер",
+ "settings.gridContainer": "Бесцветный сеточный контейнер",
+ "settings.coloredGridContainer": "Цветной сеточный контейнер"
}
diff --git a/Apptag/Localization/zh-Hans.json b/Apptag/Localization/zh-Hans.json
index 4c3ab89..6a4462d 100644
--- a/Apptag/Localization/zh-Hans.json
+++ b/Apptag/Localization/zh-Hans.json
@@ -13,7 +13,7 @@
"settings.appListStyle": "应用列表样式:",
"settings.flat": "平铺",
"settings.container": "无色容器",
- "settings.flatDesc": "\"平铺\"直接显示应用。\"无色容器\"将每个标签组放入无填充容器。\"彩色容器\"会用标签颜色填充每个容器。",
+ "settings.flatDesc": "\"平铺\"直接显示应用。\"无色容器\"和\"彩色容器\"使用瀑布流容器。\"无色网格容器\"和\"彩色网格容器\"会按等高网格行对齐应用图标。",
"settings.hideAppNames": "隐藏应用名称",
"settings.tagPosition": "标签位置:",
"settings.left": "左侧",
@@ -56,5 +56,7 @@
"tag.entertainment": "娱乐",
"tag.system": "系统优化",
"tag.productivity": "办公",
- "settings.coloredContainer": "彩色容器"
+ "settings.coloredContainer": "彩色容器",
+ "settings.gridContainer": "无色网格容器",
+ "settings.coloredGridContainer": "彩色网格容器"
}
diff --git a/Apptag/Localization/zh-Hant.json b/Apptag/Localization/zh-Hant.json
index 431ffba..5c76051 100644
--- a/Apptag/Localization/zh-Hant.json
+++ b/Apptag/Localization/zh-Hant.json
@@ -13,7 +13,7 @@
"settings.appListStyle": "應用程式列表樣式:",
"settings.flat": "平鋪",
"settings.container": "無色容器",
- "settings.flatDesc": "\"平鋪\"直接顯示應用程式。\"無色容器\"將每個標籤組放入無填充容器。\"彩色容器\"會用標籤顏色填充每個容器。",
+ "settings.flatDesc": "\"平鋪\"直接顯示應用程式。\"無色容器\"和\"彩色容器\"使用瀑布流容器。\"無色網格容器\"和\"彩色網格容器\"會按等高網格列對齊應用程式圖示。",
"settings.hideAppNames": "隱藏應用程式名稱",
"settings.tagPosition": "標籤位置:",
"settings.left": "左側",
@@ -56,5 +56,7 @@
"tag.entertainment": "娛樂",
"tag.system": "系統優化",
"tag.productivity": "辦公",
- "settings.coloredContainer": "彩色容器"
+ "settings.coloredContainer": "彩色容器",
+ "settings.gridContainer": "無色網格容器",
+ "settings.coloredGridContainer": "彩色網格容器"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea4b7fc..abdc866 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Apptag Changelog
+## [4.0.0] — 2026-05-10
+
+- 新增「无色网格容器」和「彩色网格容器」两种 App 列表视图样式
+- 网格容器按标签顺序分行排列,每行 1/2/3 个等宽容器,同一行等高,容器内通过空白网格位保持图标对齐
+- 无色网格容器继承无色容器的持久填充/点击清除交互;彩色网格容器继承彩色容器的默认填色/hover 阴影交互
+- 补充 9 语种新增视图名称与说明文案
+
## [3.2.1] — 2026-05-10
- 固定「无色容器」交互逻辑:hover 标签或容器时保持标签色填充,再次点击标签或点击容器空白处清除填充
--
Gitblit v1.9.3