LNImageFeedDetailViewController.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. //
  2. // LNImageFeedDetailViewController.swift
  3. // Gami
  4. //
  5. // Created by OneeChan on 2026/3/3.
  6. //
  7. import Foundation
  8. import UIKit
  9. import SnapKit
  10. import MJRefresh
  11. extension UIView {
  12. func pushToImageFeedDetail(id: String) {
  13. let vc = LNImageFeedDetailViewController()
  14. vc.loadDetail(id: id)
  15. navigationController?.pushViewController(vc, animated: true)
  16. }
  17. }
  18. class LNImageFeedDetailViewController: LNViewController {
  19. private let avatar = UIImageView()
  20. private let nameLabel = UILabel()
  21. private let tableView = UITableView(frame: .zero, style: .grouped)
  22. private let likeView = LNFeedLikeView()
  23. private let commentView = LNFeedCommentView()
  24. private var curDetail: LNFeedDetailVO?
  25. private var nextTag: String?
  26. private var comments: [LNFeedCommentVO] = []
  27. override func viewDidLoad() {
  28. super.viewDidLoad()
  29. setupViews()
  30. }
  31. func loadDetail(id: String) {
  32. LNFeedManager.shared.getFeedDetail(id: id) { [weak self] detail in
  33. guard let self else { return }
  34. guard let detail else {
  35. navigationController?.popViewController(animated: true)
  36. return
  37. }
  38. curDetail = detail
  39. tableView.reloadData()
  40. avatar.sd_setImage(with: URL(string: detail.avatar))
  41. nameLabel.text = detail.nickname
  42. likeView.update(id: id, liked: detail.liked, count: detail.likeCount)
  43. commentView.update(id: id, count: detail.commentCount)
  44. }
  45. nextTag = nil
  46. loadComment(id: id)
  47. }
  48. }
  49. extension LNImageFeedDetailViewController {
  50. private func loadComment(id: String) {
  51. LNFeedManager.shared.getFeedCommentList(id: id, next: nextTag) { [weak self] res in
  52. guard let self else { return }
  53. if let res {
  54. if nextTag?.isEmpty != false {
  55. comments.removeAll()
  56. tableView.mj_header?.endRefreshing()
  57. }
  58. comments.append(contentsOf: res.list)
  59. self.nextTag = nextTag
  60. tableView.reloadData()
  61. }
  62. if nextTag?.isEmpty != false {
  63. tableView.mj_footer?.endRefreshingWithNoMoreData()
  64. } else {
  65. tableView.mj_footer?.endRefreshing()
  66. }
  67. }
  68. }
  69. private func toComment(checkScroll: Bool = true) {
  70. guard let curDetail else { return }
  71. if checkScroll, !scrollToComment() {
  72. return
  73. }
  74. let panel = LNCommonInputPanel()
  75. panel.maxInput = LNFeedManager.feedCommentMaxInput
  76. panel.handler = { [weak self] comment in
  77. guard let self else { return }
  78. LNFeedManager.shared.sendFeedComment(id: curDetail.id, content: comment) { [weak self] success in
  79. guard let self else { return }
  80. guard success else { return }
  81. let item = LNFeedCommentVO()
  82. item.avatar = myUserInfo.avatar
  83. item.nickname = myUserInfo.nickname
  84. item.textContent = comment
  85. item.createdAt = Int(curTime * 1_000)
  86. comments.insert(item, at: 0)
  87. tableView.reloadSections(.init(integer: 1), with: .automatic)
  88. curDetail.commentCount += 1
  89. LNFeedManager.shared.notifyFeedCommentChanged(id: curDetail.id, count: curDetail.commentCount)
  90. }
  91. }
  92. panel.popup()
  93. }
  94. }
  95. extension LNImageFeedDetailViewController: UITableViewDataSource, UITableViewDelegate {
  96. func numberOfSections(in tableView: UITableView) -> Int {
  97. 2
  98. }
  99. func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  100. if section == 0 {
  101. 1
  102. } else {
  103. comments.count
  104. }
  105. }
  106. func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  107. if indexPath.section == 0 {
  108. let cell = tableView.dequeueReusableCell(withIdentifier: LNImageFeedHeaderCell.className, for: indexPath) as! LNImageFeedHeaderCell
  109. cell.update(curDetail)
  110. return cell
  111. } else {
  112. let cell = tableView.dequeueReusableCell(withIdentifier: LNFeedCommentCell.className, for: indexPath) as! LNFeedCommentCell
  113. cell.update(comments[indexPath.row])
  114. return cell
  115. }
  116. }
  117. func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
  118. if section == 0 {
  119. 0
  120. } else {
  121. 36
  122. }
  123. }
  124. func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
  125. if section == 0 {
  126. return nil
  127. }
  128. let container = UIView()
  129. let titleLabel = UILabel()
  130. titleLabel.font = .body_m
  131. titleLabel.textColor = .text_5
  132. titleLabel.text = .init(key: "A00304", comments.count)
  133. container.addSubview(titleLabel)
  134. titleLabel.snp.makeConstraints { make in
  135. make.centerY.equalToSuperview()
  136. make.leading.equalToSuperview().offset(16)
  137. }
  138. return container
  139. }
  140. func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
  141. 0
  142. }
  143. func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
  144. nil
  145. }
  146. }
  147. extension LNImageFeedDetailViewController {
  148. private func scrollToComment() -> Bool {
  149. if curDetail?.commentCount == 0 {
  150. return true
  151. }
  152. if tableView.contentOffset.y
  153. + tableView.bounds.height
  154. + tableView.contentInset.top
  155. > tableView.contentSize.height - 50 {
  156. return true
  157. }
  158. let rect = tableView.rectForHeader(inSection: 1)
  159. let convertedRect = tableView.convert(rect, to: tableView.superview)
  160. if convertedRect.minY - 50 <= tableView.bounds.minY + tableView.contentInset.top {
  161. return true
  162. }
  163. let indexPath = IndexPath(row: 0, section: 1)
  164. tableView.scrollToRow(at: indexPath, at: .top, animated: true)
  165. return false
  166. }
  167. private func setupViews() {
  168. setupNavBar()
  169. let bottomMenu = buildMenuView()
  170. view.addSubview(bottomMenu)
  171. bottomMenu.snp.makeConstraints { make in
  172. make.horizontalEdges.equalToSuperview()
  173. make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
  174. }
  175. let listView = buildListView()
  176. view.addSubview(listView)
  177. listView.snp.makeConstraints { make in
  178. make.horizontalEdges.equalToSuperview()
  179. make.top.equalToSuperview()
  180. make.bottom.equalTo(bottomMenu.snp.top)
  181. }
  182. }
  183. private func setupNavBar() {
  184. let menu = buildButtonMenu()
  185. setRightButton(menu)
  186. let container = UIView()
  187. avatar.layer.cornerRadius = 16
  188. avatar.clipsToBounds = true
  189. container.addSubview(avatar)
  190. avatar.snp.makeConstraints { make in
  191. make.leading.equalToSuperview()
  192. make.centerY.equalToSuperview()
  193. make.width.height.equalTo(32)
  194. }
  195. nameLabel.font = .heading_h3
  196. nameLabel.textColor = .text_5
  197. nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
  198. nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  199. container.addSubview(nameLabel)
  200. nameLabel.snp.makeConstraints { make in
  201. make.leading.equalTo(avatar.snp.trailing).offset(12)
  202. make.centerY.equalToSuperview()
  203. make.trailing.lessThanOrEqualToSuperview().offset(-37)
  204. }
  205. setTitleView(container)
  206. container.snp.makeConstraints { make in
  207. make.width.equalTo(view.bounds.width).priority(.medium)
  208. make.height.equalTo(44)
  209. }
  210. }
  211. private func buildButtonMenu() -> UIView {
  212. let moreButton = UIButton()
  213. moreButton.setImage(.icMoreFull, for: .normal)
  214. moreButton.snp.makeConstraints { make in
  215. make.width.height.equalTo(32)
  216. }
  217. moreButton.addAction(UIAction(handler: { [weak self] _ in
  218. guard let self else { return }
  219. guard let curDetail else { return }
  220. LNBottomSheetMenu.showFeedMenu(detail: curDetail, view: view)
  221. }), for: .touchUpInside)
  222. return moreButton
  223. }
  224. private func buildMenuView() -> UIView {
  225. let container = UIView()
  226. container.snp.makeConstraints { make in
  227. make.height.equalTo(58)
  228. }
  229. let comment = buildComment()
  230. container.addSubview(comment)
  231. comment.snp.makeConstraints { make in
  232. make.centerY.equalToSuperview()
  233. make.trailing.equalToSuperview().offset(-16)
  234. }
  235. let like = buildLike()
  236. container.addSubview(like)
  237. like.snp.makeConstraints { make in
  238. make.centerY.equalToSuperview()
  239. make.trailing.equalTo(comment.snp.leading).offset(-20)
  240. }
  241. let input = buildInput()
  242. container.addSubview(input)
  243. input.snp.makeConstraints { make in
  244. make.centerY.equalToSuperview()
  245. make.leading.equalToSuperview().offset(16)
  246. make.width.equalTo(190)
  247. }
  248. return container
  249. }
  250. private func buildComment() -> UIView {
  251. commentView.uiColor = .text_4
  252. commentView.onTap { [weak self] in
  253. guard let self else { return }
  254. toComment()
  255. }
  256. return commentView
  257. }
  258. private func buildLike() -> UIView {
  259. likeView.uiColor = .text_4
  260. return likeView
  261. }
  262. private func buildInput() -> UIView {
  263. let container = UIView()
  264. container.backgroundColor = .fill_2
  265. container.layer.cornerRadius = 19
  266. container.onTap { [weak self] in
  267. guard let self else { return }
  268. toComment(checkScroll: false)
  269. }
  270. container.snp.makeConstraints { make in
  271. make.height.equalTo(38)
  272. }
  273. let editIc = UIImageView()
  274. editIc.image = .icImChatMenuRemark.withTintColor(.text_2)
  275. container.addSubview(editIc)
  276. editIc.snp.makeConstraints { make in
  277. make.leading.equalToSuperview().offset(10)
  278. make.centerY.equalToSuperview()
  279. make.width.height.equalTo(24)
  280. }
  281. let tipsLabel = UILabel()
  282. tipsLabel.font = .body_m
  283. tipsLabel.textColor = .text_2
  284. tipsLabel.text = .init(key: "A00300")
  285. container.addSubview(tipsLabel)
  286. tipsLabel.snp.makeConstraints { make in
  287. make.centerY.equalToSuperview()
  288. make.leading.equalTo(editIc.snp.trailing).offset(4)
  289. make.trailing.equalToSuperview().offset(-10)
  290. }
  291. return container
  292. }
  293. private func buildListView() -> UIView {
  294. tableView.register(LNFeedCommentCell.self, forCellReuseIdentifier: LNFeedCommentCell.className)
  295. tableView.register(LNImageFeedHeaderCell.self, forCellReuseIdentifier: LNImageFeedHeaderCell.className)
  296. tableView.showsVerticalScrollIndicator = false
  297. tableView.showsHorizontalScrollIndicator = false
  298. tableView.backgroundColor = .clear
  299. tableView.separatorStyle = .none
  300. tableView.dataSource = self
  301. tableView.delegate = self
  302. tableView.allowsSelection = false
  303. let header = MJRefreshNormalHeader { [weak self] in
  304. guard let self else { return }
  305. guard let curDetail else { return }
  306. self.nextTag = nil
  307. self.loadDetail(id: curDetail.id)
  308. }
  309. header.lastUpdatedTimeLabel?.isHidden = true
  310. header.stateLabel?.isHidden = true
  311. tableView.mj_header = header
  312. let footer = MJRefreshAutoNormalFooter { [weak self] in
  313. guard let self else { return }
  314. guard let curDetail else { return }
  315. self.loadComment(id: curDetail.id)
  316. }
  317. footer.setTitle("", for: .noMoreData)
  318. footer.setTitle(.init(key: "A00046"), for: .idle)
  319. tableView.mj_footer = footer
  320. return tableView
  321. }
  322. }
  323. private class LNImageFeedHeaderCell: UITableViewCell {
  324. private let stackView = UIStackView()
  325. private let pageControl = UIPageControl()
  326. private let contentLabel = UILabel()
  327. private let timeLabel = UILabel()
  328. private var curImageUrls: [String] = []
  329. override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
  330. super.init(style: style, reuseIdentifier: reuseIdentifier)
  331. setupViews()
  332. }
  333. func update(_ detail: LNFeedDetailVO?) {
  334. contentLabel.text = detail?.textContent
  335. contentLabel.superview?.isHidden = detail?.textContent.isEmpty != false
  336. timeLabel.text = TimeInterval((detail?.createdAt ?? 0) / 1_000).tencentIMTimeDesc
  337. let urls = detail?.medias.compactMap { $0.type == .image ? $0.url : nil} ?? []
  338. if urls == curImageUrls {
  339. return
  340. }
  341. curImageUrls = urls
  342. if urls.isEmpty {
  343. stackView.superview?.isHidden = true
  344. pageControl.superview?.isHidden = true
  345. return
  346. }
  347. stackView.arrangedSubviews.forEach {
  348. stackView.removeArrangedSubview($0)
  349. $0.removeFromSuperview()
  350. }
  351. stackView.superview?.isHidden = false
  352. let size = urls[0].extractSize
  353. let scale = (size.height / size.width).bounded(min: 169.0/375.0, max: 4.0/3.0)
  354. for (index, url) in urls.enumerated() {
  355. let imageView = buildImageView()
  356. imageView.sd_setImage(with: URL(string: url))
  357. imageView.onTap { [weak self] in
  358. guard let self else { return }
  359. presentImagePreview(urls, index)
  360. }
  361. stackView.addArrangedSubview(imageView)
  362. if index == 0 {
  363. imageView.snp.makeConstraints { make in
  364. make.width.equalTo(stackView.superview!)
  365. make.height.equalTo(imageView.snp.width).multipliedBy(scale)
  366. }
  367. }
  368. }
  369. pageControl.numberOfPages = urls.count
  370. pageControl.superview?.isHidden = false
  371. }
  372. required init?(coder: NSCoder) {
  373. fatalError("init(coder:) has not been implemented")
  374. }
  375. }
  376. extension LNImageFeedHeaderCell: UIScrollViewDelegate {
  377. func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
  378. checkPageIndex(scrollView)
  379. }
  380. func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
  381. checkPageIndex(scrollView)
  382. }
  383. func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
  384. checkPageIndex(scrollView)
  385. }
  386. }
  387. extension LNImageFeedHeaderCell {
  388. private func checkPageIndex(_ scrollView: UIScrollView) {
  389. let page = scrollView.contentOffset.x / scrollView.bounds.width
  390. pageControl.currentPage = Int(page)
  391. }
  392. private func setupViews() {
  393. let stackView = UIStackView()
  394. stackView.axis = .vertical
  395. stackView.spacing = 8
  396. contentView.addSubview(stackView)
  397. stackView.snp.makeConstraints { make in
  398. make.horizontalEdges.equalToSuperview()
  399. make.top.equalToSuperview()
  400. }
  401. stackView.addArrangedSubview(buildAlbum())
  402. stackView.addArrangedSubview(buildPage())
  403. stackView.addArrangedSubview(buildContent())
  404. stackView.addArrangedSubview(buildTime())
  405. let line = UIView()
  406. line.backgroundColor = .fill_2
  407. contentView.addSubview(line)
  408. line.snp.makeConstraints { make in
  409. make.horizontalEdges.equalToSuperview().inset(16)
  410. make.top.equalTo(stackView.snp.bottom).offset(16)
  411. make.bottom.equalToSuperview().offset(-6).priority(.medium)
  412. make.height.equalTo(1)
  413. }
  414. }
  415. private func buildAlbum() -> UIView {
  416. let scrollView = UIScrollView()
  417. scrollView.isPagingEnabled = true
  418. scrollView.showsVerticalScrollIndicator = false
  419. scrollView.showsHorizontalScrollIndicator = false
  420. scrollView.isHidden = true
  421. scrollView.delegate = self
  422. scrollView.backgroundColor = .primary_1
  423. stackView.distribution = .fillEqually
  424. scrollView.addSubview(stackView)
  425. stackView.snp.makeConstraints { make in
  426. make.horizontalEdges.equalToSuperview()
  427. make.verticalEdges.equalToSuperview()
  428. make.height.equalToSuperview()
  429. }
  430. return scrollView
  431. }
  432. private func buildPage() -> UIView {
  433. let container = UIView()
  434. container.isHidden = true
  435. pageControl.pageIndicatorTintColor = .text_2
  436. pageControl.currentPageIndicatorTintColor = .text_4
  437. pageControl.preferredIndicatorImage = UIImage.image(for: .text_2, size: 4, cornerRadius: 2)
  438. pageControl.hidesForSinglePage = true
  439. container.addSubview(pageControl)
  440. pageControl.snp.makeConstraints { make in
  441. make.centerX.equalToSuperview()
  442. make.verticalEdges.equalToSuperview().inset(2)
  443. make.height.equalTo(4)
  444. }
  445. return container
  446. }
  447. private func buildContent() -> UIView {
  448. let container = UIView()
  449. contentLabel.font = .body_m
  450. contentLabel.textColor = .text_5
  451. contentLabel.text = " "
  452. contentLabel.numberOfLines = 0
  453. container.addSubview(contentLabel)
  454. contentLabel.snp.makeConstraints { make in
  455. make.horizontalEdges.equalToSuperview().inset(16)
  456. make.verticalEdges.equalToSuperview()
  457. }
  458. return container
  459. }
  460. private func buildTime() -> UIView {
  461. let container = UIView()
  462. timeLabel.font = .body_xs
  463. timeLabel.textColor = .text_4
  464. timeLabel.text = " "
  465. container.addSubview(timeLabel)
  466. timeLabel.snp.makeConstraints { make in
  467. make.horizontalEdges.equalToSuperview().inset(16)
  468. make.verticalEdges.equalToSuperview()
  469. }
  470. return container
  471. }
  472. private func buildImageView() -> UIImageView {
  473. let imageView = UIImageView()
  474. imageView.contentMode = .scaleAspectFit
  475. return imageView
  476. }
  477. }