// // LNImageFeedDetailViewController.swift // Gami // // Created by OneeChan on 2026/3/3. // import Foundation import UIKit import SnapKit import MJRefresh extension UIView { func pushToImageFeedDetail(id: String) { let vc = LNImageFeedDetailViewController() vc.loadDetail(id: id) navigationController?.pushViewController(vc, animated: true) } } class LNImageFeedDetailViewController: LNViewController { private let avatar = UIImageView() private let nameLabel = UILabel() private let tableView = UITableView(frame: .zero, style: .grouped) private let likeView = LNFeedLikeView() private let commentView = LNFeedCommentView() private var curDetail: LNFeedDetailVO? private var nextTag: String? private var comments: [LNFeedCommentVO] = [] override func viewDidLoad() { super.viewDidLoad() setupViews() } func loadDetail(id: String) { LNFeedManager.shared.getFeedDetail(id: id) { [weak self] detail in guard let self else { return } guard let detail else { navigationController?.popViewController(animated: true) return } curDetail = detail tableView.reloadData() avatar.sd_setImage(with: URL(string: detail.avatar)) nameLabel.text = detail.nickname likeView.update(id: id, liked: detail.liked, count: detail.likeCount) commentView.update(id: id, count: detail.commentCount) } nextTag = nil loadComment(id: id) } } extension LNImageFeedDetailViewController { private func loadComment(id: String) { LNFeedManager.shared.getFeedCommentList(id: id, next: nextTag) { [weak self] res in guard let self else { return } if let res { if nextTag?.isEmpty != false { comments.removeAll() tableView.mj_header?.endRefreshing() } comments.append(contentsOf: res.list) self.nextTag = nextTag tableView.reloadData() } if nextTag?.isEmpty != false { tableView.mj_footer?.endRefreshingWithNoMoreData() } else { tableView.mj_footer?.endRefreshing() } } } private func toComment(checkScroll: Bool = true) { guard let curDetail else { return } if checkScroll, !scrollToComment() { return } let panel = LNCommonInputPanel() panel.maxInput = LNFeedManager.feedCommentMaxInput panel.handler = { [weak self] comment in guard let self else { return } LNFeedManager.shared.sendFeedComment(id: curDetail.id, content: comment) { [weak self] success in guard let self else { return } guard success else { return } let item = LNFeedCommentVO() item.avatar = myUserInfo.avatar item.nickname = myUserInfo.nickname item.textContent = comment item.createdAt = Int(curTime * 1_000) comments.insert(item, at: 0) tableView.reloadSections(.init(integer: 1), with: .automatic) curDetail.commentCount += 1 LNFeedManager.shared.notifyFeedCommentChanged(id: curDetail.id, count: curDetail.commentCount) } } panel.popup() } } extension LNImageFeedDetailViewController: UITableViewDataSource, UITableViewDelegate { func numberOfSections(in tableView: UITableView) -> Int { 2 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == 0 { 1 } else { comments.count } } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { if indexPath.section == 0 { let cell = tableView.dequeueReusableCell(withIdentifier: LNImageFeedHeaderCell.className, for: indexPath) as! LNImageFeedHeaderCell cell.update(curDetail) return cell } else { let cell = tableView.dequeueReusableCell(withIdentifier: LNFeedCommentCell.className, for: indexPath) as! LNFeedCommentCell cell.update(comments[indexPath.row]) return cell } } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { if section == 0 { 0 } else { 36 } } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { if section == 0 { return nil } let container = UIView() let titleLabel = UILabel() titleLabel.font = .body_m titleLabel.textColor = .text_5 titleLabel.text = .init(key: "A00304", comments.count) container.addSubview(titleLabel) titleLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(16) } return container } func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { 0 } func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { nil } } extension LNImageFeedDetailViewController { private func scrollToComment() -> Bool { if curDetail?.commentCount == 0 { return true } if tableView.contentOffset.y + tableView.bounds.height + tableView.contentInset.top > tableView.contentSize.height - 50 { return true } let rect = tableView.rectForHeader(inSection: 1) let convertedRect = tableView.convert(rect, to: tableView.superview) if convertedRect.minY - 50 <= tableView.bounds.minY + tableView.contentInset.top { return true } let indexPath = IndexPath(row: 0, section: 1) tableView.scrollToRow(at: indexPath, at: .top, animated: true) return false } private func setupViews() { setupNavBar() let bottomMenu = buildMenuView() view.addSubview(bottomMenu) bottomMenu.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom) } let listView = buildListView() view.addSubview(listView) listView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() make.bottom.equalTo(bottomMenu.snp.top) } } private func setupNavBar() { let menu = buildButtonMenu() setRightButton(menu) let container = UIView() avatar.layer.cornerRadius = 16 avatar.clipsToBounds = true container.addSubview(avatar) avatar.snp.makeConstraints { make in make.leading.equalToSuperview() make.centerY.equalToSuperview() make.width.height.equalTo(32) } nameLabel.font = .heading_h3 nameLabel.textColor = .text_5 nameLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) container.addSubview(nameLabel) nameLabel.snp.makeConstraints { make in make.leading.equalTo(avatar.snp.trailing).offset(12) make.centerY.equalToSuperview() make.trailing.lessThanOrEqualToSuperview().offset(-37) } setTitleView(container) container.snp.makeConstraints { make in make.width.equalTo(view.bounds.width).priority(.medium) make.height.equalTo(44) } } private func buildButtonMenu() -> UIView { let moreButton = UIButton() moreButton.setImage(.icMoreFull, for: .normal) moreButton.snp.makeConstraints { make in make.width.height.equalTo(32) } moreButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } guard let curDetail else { return } LNBottomSheetMenu.showFeedMenu(detail: curDetail, view: view) }), for: .touchUpInside) return moreButton } private func buildMenuView() -> UIView { let container = UIView() container.snp.makeConstraints { make in make.height.equalTo(58) } let comment = buildComment() container.addSubview(comment) comment.snp.makeConstraints { make in make.centerY.equalToSuperview() make.trailing.equalToSuperview().offset(-16) } let like = buildLike() container.addSubview(like) like.snp.makeConstraints { make in make.centerY.equalToSuperview() make.trailing.equalTo(comment.snp.leading).offset(-20) } let input = buildInput() container.addSubview(input) input.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(16) make.width.equalTo(190) } return container } private func buildComment() -> UIView { commentView.uiColor = .text_4 commentView.onTap { [weak self] in guard let self else { return } toComment() } return commentView } private func buildLike() -> UIView { likeView.uiColor = .text_4 return likeView } private func buildInput() -> UIView { let container = UIView() container.backgroundColor = .fill_2 container.layer.cornerRadius = 19 container.onTap { [weak self] in guard let self else { return } toComment(checkScroll: false) } container.snp.makeConstraints { make in make.height.equalTo(38) } let editIc = UIImageView() editIc.image = .icImChatMenuRemark.withTintColor(.text_2) container.addSubview(editIc) editIc.snp.makeConstraints { make in make.leading.equalToSuperview().offset(10) make.centerY.equalToSuperview() make.width.height.equalTo(24) } let tipsLabel = UILabel() tipsLabel.font = .body_m tipsLabel.textColor = .text_2 tipsLabel.text = .init(key: "A00300") container.addSubview(tipsLabel) tipsLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalTo(editIc.snp.trailing).offset(4) make.trailing.equalToSuperview().offset(-10) } return container } private func buildListView() -> UIView { tableView.register(LNFeedCommentCell.self, forCellReuseIdentifier: LNFeedCommentCell.className) tableView.register(LNImageFeedHeaderCell.self, forCellReuseIdentifier: LNImageFeedHeaderCell.className) tableView.showsVerticalScrollIndicator = false tableView.showsHorizontalScrollIndicator = false tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.dataSource = self tableView.delegate = self tableView.allowsSelection = false let header = MJRefreshNormalHeader { [weak self] in guard let self else { return } guard let curDetail else { return } self.nextTag = nil self.loadDetail(id: curDetail.id) } header.lastUpdatedTimeLabel?.isHidden = true header.stateLabel?.isHidden = true tableView.mj_header = header let footer = MJRefreshAutoNormalFooter { [weak self] in guard let self else { return } guard let curDetail else { return } self.loadComment(id: curDetail.id) } footer.setTitle("", for: .noMoreData) footer.setTitle(.init(key: "A00046"), for: .idle) tableView.mj_footer = footer return tableView } } private class LNImageFeedHeaderCell: UITableViewCell { private let stackView = UIStackView() private let pageControl = UIPageControl() private let contentLabel = UILabel() private let timeLabel = UILabel() private var curImageUrls: [String] = [] override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupViews() } func update(_ detail: LNFeedDetailVO?) { contentLabel.text = detail?.textContent contentLabel.superview?.isHidden = detail?.textContent.isEmpty != false timeLabel.text = TimeInterval((detail?.createdAt ?? 0) / 1_000).tencentIMTimeDesc let urls = detail?.medias.compactMap { $0.type == .image ? $0.url : nil} ?? [] if urls == curImageUrls { return } curImageUrls = urls if urls.isEmpty { stackView.superview?.isHidden = true pageControl.superview?.isHidden = true return } stackView.arrangedSubviews.forEach { stackView.removeArrangedSubview($0) $0.removeFromSuperview() } stackView.superview?.isHidden = false let size = urls[0].extractSize let scale = (size.height / size.width).bounded(min: 169.0/375.0, max: 4.0/3.0) for (index, url) in urls.enumerated() { let imageView = buildImageView() imageView.sd_setImage(with: URL(string: url)) imageView.onTap { [weak self] in guard let self else { return } presentImagePreview(urls, index) } stackView.addArrangedSubview(imageView) if index == 0 { imageView.snp.makeConstraints { make in make.width.equalTo(stackView.superview!) make.height.equalTo(imageView.snp.width).multipliedBy(scale) } } } pageControl.numberOfPages = urls.count pageControl.superview?.isHidden = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension LNImageFeedHeaderCell: UIScrollViewDelegate { func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { checkPageIndex(scrollView) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { checkPageIndex(scrollView) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { checkPageIndex(scrollView) } } extension LNImageFeedHeaderCell { private func checkPageIndex(_ scrollView: UIScrollView) { let page = scrollView.contentOffset.x / scrollView.bounds.width pageControl.currentPage = Int(page) } private func setupViews() { let stackView = UIStackView() stackView.axis = .vertical stackView.spacing = 8 contentView.addSubview(stackView) stackView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalToSuperview() } stackView.addArrangedSubview(buildAlbum()) stackView.addArrangedSubview(buildPage()) stackView.addArrangedSubview(buildContent()) stackView.addArrangedSubview(buildTime()) let line = UIView() line.backgroundColor = .fill_2 contentView.addSubview(line) line.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(16) make.top.equalTo(stackView.snp.bottom).offset(16) make.bottom.equalToSuperview().offset(-6).priority(.medium) make.height.equalTo(1) } } private func buildAlbum() -> UIView { let scrollView = UIScrollView() scrollView.isPagingEnabled = true scrollView.showsVerticalScrollIndicator = false scrollView.showsHorizontalScrollIndicator = false scrollView.isHidden = true scrollView.delegate = self scrollView.backgroundColor = .primary_1 stackView.distribution = .fillEqually scrollView.addSubview(stackView) stackView.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.verticalEdges.equalToSuperview() make.height.equalToSuperview() } return scrollView } private func buildPage() -> UIView { let container = UIView() container.isHidden = true pageControl.pageIndicatorTintColor = .text_2 pageControl.currentPageIndicatorTintColor = .text_4 pageControl.preferredIndicatorImage = UIImage.image(for: .text_2, size: 4, cornerRadius: 2) pageControl.hidesForSinglePage = true container.addSubview(pageControl) pageControl.snp.makeConstraints { make in make.centerX.equalToSuperview() make.verticalEdges.equalToSuperview().inset(2) make.height.equalTo(4) } return container } private func buildContent() -> UIView { let container = UIView() contentLabel.font = .body_m contentLabel.textColor = .text_5 contentLabel.text = " " contentLabel.numberOfLines = 0 container.addSubview(contentLabel) contentLabel.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(16) make.verticalEdges.equalToSuperview() } return container } private func buildTime() -> UIView { let container = UIView() timeLabel.font = .body_xs timeLabel.textColor = .text_4 timeLabel.text = " " container.addSubview(timeLabel) timeLabel.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview().inset(16) make.verticalEdges.equalToSuperview() } return container } private func buildImageView() -> UIImageView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFit return imageView } }