77
88import UIKit
99
10- public final class STMarkdownTableView : UIView {
10+ // MARK: - STMarkdownTableHeaderItem
11+
12+ /// 顶部工具条的单个按钮描述。外界可组合内置工厂方法或自定义,赋给 `STMarkdownTableView.headerItems`。
13+ public struct STMarkdownTableHeaderItem {
14+ public let identifier : String
15+ public let image : UIImage ?
16+ /// handler 在主线程调用,参数为触发按钮的 tableView,便于访问数据或调用 renderFullTableImage()。
17+ public let handler : ( STMarkdownTableView ) -> Void
18+
19+ public init ( identifier: String , image: UIImage ? , handler: @escaping ( STMarkdownTableView ) -> Void ) {
20+ self . identifier = identifier
21+ self . image = image
22+ self . handler = handler
23+ }
24+
25+ /// 复制按钮:将表格纯文本写入剪贴板,并触发 onCopyTable 回调。
26+ public static func copy( ) -> STMarkdownTableHeaderItem {
27+ STMarkdownTableHeaderItem (
28+ identifier: " copy " ,
29+ image: UIImage ( systemName: " doc.on.doc " )
30+ ) { tableView in
31+ guard let tableData = tableView. tableData else { return }
32+ UIPasteboard . general. string = tableData. plainText ( )
33+ tableView. onCopyTable ? ( )
34+ tableView. showCopyFeedback ( )
35+ }
36+ }
37+
38+ /// 下载按钮:触发 onDownloadTable 回调,由宿主实现具体保存逻辑。
39+ public static func download( ) -> STMarkdownTableHeaderItem {
40+ STMarkdownTableHeaderItem (
41+ identifier: " download " ,
42+ image: UIImage ( systemName: " square.and.arrow.down " )
43+ ) { tableView in
44+ guard let tableData = tableView. tableData else { return }
45+ tableView. onDownloadTable ? ( tableData)
46+ }
47+ }
48+
49+ /// 全屏按钮:触发 onExpandTable 回调,与长按展开行为一致。
50+ public static func fullscreen( ) -> STMarkdownTableHeaderItem {
51+ STMarkdownTableHeaderItem (
52+ identifier: " fullscreen " ,
53+ image: UIImage ( systemName: " arrow.up.left.and.arrow.down.right " )
54+ ) { tableView in
55+ guard let tableData = tableView. tableData else { return }
56+ tableView. onExpandTable ? ( tableData)
57+ }
58+ }
59+ }
60+
61+ // MARK: - STMarkdownTableView
62+
63+ open class STMarkdownTableView : UIView {
1164
1265 /// 表格数据 façade。流式逐行追加(同列数、已有行内容不变、行数严格增多)时,
1366 /// 用 `performBatchUpdates` 插入新 section 并逐行淡入;否则全量 `reloadData()`。
@@ -31,21 +84,36 @@ public final class STMarkdownTableView: UIView {
3184 private var isApplyingAppend = false
3285
3386 public var style : STMarkdownStyle = . default {
34- didSet { self . applyStyle ( ) ; self . reloadData ( ) }
87+ didSet {
88+ self . applyStyle ( )
89+ self . applyStyleHeaderItems ( )
90+ self . reloadData ( )
91+ }
3592 }
3693
3794 public var onCitationTap : ( ( String ) -> Void ) ?
3895 public var onExpandTable : ( ( STMarkdownTableViewModel ) -> Void ) ?
3996 public var onCopyTable : ( ( ) -> Void ) ?
4097 public var onDownloadTable : ( ( STMarkdownTableViewModel ) -> Void ) ?
4198
99+ /// 顶部工具条按钮列表。默认为 [copy, download, fullscreen],赋新值后立即重建按钮栈。
100+ /// 赋空数组可隐藏所有按钮但保留标题行;配合 showsHeader=false 可完全隐藏工具条。
101+ public var headerItems : [ STMarkdownTableHeaderItem ] = [ ] {
102+ didSet { self . rebuildButtonStack ( ) }
103+ }
104+
105+ /// 顶部工具条左侧标题,默认 "表格"。
106+ public var headerTitle : String = " 表格 " {
107+ didSet { self . titleLabel. text = self . headerTitle }
108+ }
109+
42110 /// 顶部工具条高度(圆角卡片化后预留给「表格 / 复制 / 下载 / 全屏」)。
43- public static let headerHeight : CGFloat = 44
111+ public static let headerHeight : CGFloat = 41
44112 /// 整块表格圆角半径。
45113 public var cornerRadius : CGFloat = 10 {
46114 didSet { self . layer. cornerRadius = self . cornerRadius }
47115 }
48- /// 是否展示顶部工具条。全屏详情页关闭(自带关闭按钮,避免重复表头与“ 全屏中再全屏” )。
116+ /// 是否展示顶部工具条。全屏详情页关闭(自带关闭按钮,避免重复表头与" 全屏中再全屏" )。
49117 public var showsHeader : Bool = true {
50118 didSet {
51119 guard oldValue != self . showsHeader else { return }
@@ -55,18 +123,18 @@ public final class STMarkdownTableView: UIView {
55123 }
56124 }
57125
126+ /// 单元格内边距,影响列宽/行高计算,与 computeSize 保持一致。
127+ public var cellInsets : UIEdgeInsets = UIEdgeInsets ( top: 8 , left: 10 , bottom: 8 , right: 10 )
128+
58129 private let gridLayout : STMarkdownTableGridLayout
59130 private let collectionView : UICollectionView
60- private let cellInsets = UIEdgeInsets ( top: 8 , left: 10 , bottom: 8 , right: 10 )
61131
62132 private let headerBar = UIView ( )
63133 private let titleLabel = UILabel ( )
64134 private let buttonStack = UIStackView ( )
65- private let copyButton = UIButton ( type: . system)
66- private let downloadButton = UIButton ( type: . system)
67- private let fullscreenButton = UIButton ( type: . system)
68135 private let headerSeparator = UIView ( )
69136 private var copyResetWorkItem : DispatchWorkItem ?
137+ private weak var copyButtonRef : UIButton ?
70138 private weak var expandGesture : UILongPressGestureRecognizer ?
71139
72140 /// 全屏详情态:开启上下/左右滚动条与回弹,并关闭内置「长按展开」手势(由详情页接管长按菜单)。
@@ -88,14 +156,14 @@ public final class STMarkdownTableView: UIView {
88156 super. init ( frame: . zero)
89157 self . clipsToBounds = true
90158 self . layer. cornerRadius = self . cornerRadius
91- self . layer. borderWidth = 0.5
159+ self . layer. borderWidth = 1
92160 self . setupCollectionView ( )
93161 self . setupHeader ( )
162+ self . headerItems = style. tableHeaderItems ?? self . makeDefaultHeaderItems ( )
94163 self . applyStyle ( )
95164 }
96165
97- @available ( * , unavailable)
98- required init ? ( coder: NSCoder ) {
166+ public required init ? ( coder: NSCoder ) {
99167 fatalError ( " init(coder:) has not been implemented " )
100168 }
101169
@@ -122,28 +190,12 @@ public final class STMarkdownTableView: UIView {
122190 }
123191
124192 private func setupHeader( ) {
125- self . titleLabel. text = " 表格 "
193+ self . titleLabel. text = self . headerTitle
126194 self . titleLabel. font = UIFont . st_systemFont ( ofSize: 14 , weight: . medium)
127195
128- self . copyButton. setImage ( UIImage ( systemName: " doc.on.doc " ) , for: . normal)
129- self . copyButton. addTarget ( self , action: #selector( self . handleCopy) , for: . touchUpInside)
130- self . downloadButton. setImage ( UIImage ( systemName: " square.and.arrow.down " ) , for: . normal)
131- self . downloadButton. addTarget ( self , action: #selector( self . handleDownload) , for: . touchUpInside)
132- self . fullscreenButton. setImage ( UIImage ( systemName: " arrow.up.left.and.arrow.down.right " ) , for: . normal)
133- self . fullscreenButton. addTarget ( self , action: #selector( self . handleFullscreen) , for: . touchUpInside)
134-
135- let imageConfig = UIImage . SymbolConfiguration ( pointSize: 15 , weight: . regular)
136- for button in [ self . copyButton, self . downloadButton, self . fullscreenButton] {
137- button. setPreferredSymbolConfiguration ( imageConfig, forImageIn: . normal)
138- button. widthAnchor. constraint ( equalToConstant: 30 ) . isActive = true
139- }
140-
141196 self . buttonStack. axis = . horizontal
142197 self . buttonStack. alignment = . center
143198 self . buttonStack. spacing = 6
144- self . buttonStack. addArrangedSubview ( self . copyButton)
145- self . buttonStack. addArrangedSubview ( self . downloadButton)
146- self . buttonStack. addArrangedSubview ( self . fullscreenButton)
147199
148200 self . titleLabel. translatesAutoresizingMaskIntoConstraints = false
149201 self . buttonStack. translatesAutoresizingMaskIntoConstraints = false
@@ -168,24 +220,61 @@ public final class STMarkdownTableView: UIView {
168220 ] )
169221 }
170222
223+ /// 从 headerItems 重建 buttonStack 中的所有按钮,每次 headerItems 变更时调用。
224+ private func rebuildButtonStack( ) {
225+ self . buttonStack. arrangedSubviews. forEach { $0. removeFromSuperview ( ) }
226+ let symbolConfig = UIImage . SymbolConfiguration ( pointSize: 15 , weight: . regular)
227+ let buttonWidth = self . style. tableHeaderButtonWidth
228+ let secondaryColor = ( self . style. tableHeaderTextColor ?? self . style. textColor) . withAlphaComponent ( 0.6 )
229+
230+ for item in self . headerItems {
231+ let button = UIButton ( type: . system)
232+ let isSystemSymbol = item. image? . isSymbolImage ?? false
233+ if isSystemSymbol {
234+ button. setPreferredSymbolConfiguration ( symbolConfig, forImageIn: . normal)
235+ }
236+ button. setImage ( item. image, for: . normal)
237+ button. tintColor = secondaryColor
238+ button. widthAnchor. constraint ( equalToConstant: buttonWidth) . isActive = true
239+ button. accessibilityIdentifier = item. identifier
240+ button. addAction ( UIAction { [ weak self] _ in
241+ guard let self else { return }
242+ item. handler ( self )
243+ } , for: . touchUpInside)
244+ if item. identifier == " copy " {
245+ self . copyButtonRef = button
246+ }
247+ self . buttonStack. addArrangedSubview ( button)
248+ }
249+ }
250+
171251 private func applyStyle( ) {
172252 let borderColor = self . style. tableBorderColor ?? UIColor . separator
173253 self . collectionView. backgroundColor = borderColor
174254 self . backgroundColor = borderColor
175255 self . gridLayout. interItemSpacing = 0.5
176256 self . gridLayout. lineSpacing = 0.5
257+ self . gridLayout. minimumRowHeight = self . style. tableMinimumRowHeight
177258 self . layer. borderColor = borderColor. cgColor
259+ self . layer. maskedCorners = self . style. tableCornerMask
178260
179- let headerBg = self . style. tableBackgroundColor ?? UIColor . secondarySystemBackground
261+ let headerBg = self . style. tableHeaderBarBackgroundColor
262+ ?? self . style. tableBackgroundColor
263+ ?? UIColor . secondarySystemBackground
180264 let secondaryColor = ( self . style. tableHeaderTextColor ?? self . style. textColor) . withAlphaComponent ( 0.6 )
181265 self . headerBar. backgroundColor = headerBg
182266 self . headerSeparator. backgroundColor = borderColor
183267 self . titleLabel. textColor = secondaryColor
184- for button in [ self . copyButton , self . downloadButton , self . fullscreenButton ] {
185- button . tintColor = secondaryColor
268+ self . buttonStack . arrangedSubviews . compactMap { $0 as? UIButton } . forEach {
269+ $0 . tintColor = secondaryColor
186270 }
187271 }
188272
273+ /// style 变更时同步按钮项(优先用 style.tableHeaderItems,否则 makeDefaultHeaderItems)。
274+ private func applyStyleHeaderItems( ) {
275+ self . headerItems = self . style. tableHeaderItems ?? self . makeDefaultHeaderItems ( )
276+ }
277+
189278 public override func layoutSubviews( ) {
190279 super. layoutSubviews ( )
191280 let headerHeight = self . showsHeader ? Self . headerHeight : 0
@@ -243,7 +332,7 @@ public final class STMarkdownTableView: UIView {
243332 metrics: STMarkdownTableGridLayout . ComputeSizeMetrics (
244333 fillWidth: true ,
245334 containerWidth: containerWidth,
246- minimumRowHeight: 35 ,
335+ minimumRowHeight: style . tableMinimumRowHeight ,
247336 minimumColumnWidth: 56 ,
248337 maximumColumnWidth: 360 ,
249338 interItemSpacing: 0.5 ,
@@ -419,28 +508,29 @@ public final class STMarkdownTableView: UIView {
419508
420509 @objc private func handleExpandGesture( _ gestureRecognizer: UILongPressGestureRecognizer ) {
421510 guard gestureRecognizer. state == . began else { return }
422- self . expandTableIfPossible ( )
423- }
424-
425- private func expandTableIfPossible( ) {
426511 guard let tableData else { return }
427512 self . onExpandTable ? ( tableData)
428513 }
429514
430- @objc private func handleCopy( ) {
431- guard let tableData else { return }
432- UIPasteboard . general. string = tableData. plainText ( )
433- self . onCopyTable ? ( )
434- self . showCopyFeedback ( )
435- }
515+ // MARK: - Open Overridable
436516
437- @objc private func handleDownload( ) {
438- guard let tableData else { return }
439- self . onDownloadTable ? ( tableData)
517+ /// 返回顶部工具条的默认按钮列表 [复制, 下载, 全屏]。子类可 override 替换默认集合。
518+ /// 外界也可在初始化后直接赋 headerItems 覆盖,无需子类化。
519+ open func makeDefaultHeaderItems( ) -> [ STMarkdownTableHeaderItem ] {
520+ [ . copy( ) , . download( ) , . fullscreen( ) ]
440521 }
441522
442- @objc private func handleFullscreen( ) {
443- self . expandTableIfPossible ( )
523+ /// 复制成功后的视觉反馈。默认将图标切换为对勾,~1.2s 后还原。
524+ /// 子类可 override 接入宿主 Toast/HUD 系统。
525+ open func showCopyFeedback( ) {
526+ self . copyResetWorkItem? . cancel ( )
527+ let originalImage = self . copyButtonRef? . image ( for: . normal)
528+ self . copyButtonRef? . setImage ( UIImage ( systemName: " checkmark " ) , for: . normal)
529+ let workItem = DispatchWorkItem { [ weak self] in
530+ self ? . copyButtonRef? . setImage ( originalImage, for: . normal)
531+ }
532+ self . copyResetWorkItem = workItem
533+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + 1.2 , execute: workItem)
444534 }
445535
446536 /// 将整张表格(含离屏行列)渲染为图片,供「复制为图片 / 保存到相册」使用。
@@ -470,17 +560,6 @@ public final class STMarkdownTableView: UIView {
470560 self . collectionView. layoutIfNeeded ( )
471561 return image
472562 }
473-
474- /// 复制成功后将图标临时切换为对勾,~1.2s 后还原,提供轻量内建反馈(无需宿主接线)。
475- private func showCopyFeedback( ) {
476- self . copyResetWorkItem? . cancel ( )
477- self . copyButton. setImage ( UIImage ( systemName: " checkmark " ) , for: . normal)
478- let workItem = DispatchWorkItem { [ weak self] in
479- self ? . copyButton. setImage ( UIImage ( systemName: " doc.on.doc " ) , for: . normal)
480- }
481- self . copyResetWorkItem = workItem
482- DispatchQueue . main. asyncAfter ( deadline: . now( ) + 1.2 , execute: workItem)
483- }
484563}
485564
486565extension STMarkdownTableView : UICollectionViewDelegate {
0 commit comments