333 lines
13 KiB
Swift
333 lines
13 KiB
Swift
import UIKit
|
||
|
||
// MARK: - GalleryOverlayView
|
||
|
||
/// UIKit overlay for gallery controls — top bar, bottom bar, counter badge, caption.
|
||
/// Bottom bar: Telegram parity — Forward (left) + Draw+Captions (center capsule) + Delete (right).
|
||
/// "..." menu: custom popup (Telegram parity) — not system UIMenu.
|
||
final class GalleryOverlayView: UIView {
|
||
|
||
var onBack: (() -> Void)?
|
||
var onMenu: (() -> Void)?
|
||
var onForward: (() -> Void)?
|
||
var onDraw: (() -> Void)?
|
||
var onCaptions: (() -> Void)?
|
||
var onDelete: (() -> Void)?
|
||
|
||
private(set) var isControlsVisible = true
|
||
|
||
// Top bar
|
||
private let topContainer = UIView()
|
||
private let backButton = UIButton(type: .system)
|
||
private let menuButton = UIButton(type: .system)
|
||
private let titleLabel = UILabel()
|
||
private let subtitleLabel = UILabel()
|
||
private let titleGlass = TelegramGlassUIView(frame: .zero)
|
||
private let backGlass = TelegramGlassUIView(frame: .zero)
|
||
private let menuGlass = TelegramGlassUIView(frame: .zero)
|
||
|
||
// Counter badge
|
||
private let counterLabel = UILabel()
|
||
private let counterGlass = TelegramGlassUIView(frame: .zero)
|
||
private let counterContainer = UIView()
|
||
|
||
// Bottom bar — Telegram parity: Forward (left) + Draw+Captions (center) + Delete (right)
|
||
private let bottomContainer = UIView()
|
||
private let leftGlass = TelegramGlassUIView(frame: .zero)
|
||
private let centerGlass = TelegramGlassUIView(frame: .zero)
|
||
private let rightGlass = TelegramGlassUIView(frame: .zero)
|
||
private let forwardButton = UIButton(type: .system)
|
||
private let drawButton = UIButton(type: .system)
|
||
private let captionsButton = UIButton(type: .system)
|
||
private let deleteButton = UIButton(type: .system)
|
||
private let centerSeparator = UIView()
|
||
|
||
// Caption (above bottom bar)
|
||
private let captionContainer = UIView()
|
||
private let captionLabel = UILabel()
|
||
|
||
private let topButtonSize: CGFloat = 44
|
||
private let bottomBarH: CGFloat = 44
|
||
|
||
// MARK: - Adaptive Colors
|
||
|
||
private static let primaryColor = UIColor { traits in
|
||
traits.userInterfaceStyle == .dark ? .white : .black
|
||
}
|
||
|
||
private static let secondaryColor = UIColor { traits in
|
||
traits.userInterfaceStyle == .dark
|
||
? UIColor.white.withAlphaComponent(0.6)
|
||
: UIColor.black.withAlphaComponent(0.5)
|
||
}
|
||
|
||
private static let separatorColor = UIColor { traits in
|
||
traits.userInterfaceStyle == .dark
|
||
? UIColor.white.withAlphaComponent(0.2)
|
||
: UIColor.black.withAlphaComponent(0.15)
|
||
}
|
||
|
||
// MARK: - Init
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
isUserInteractionEnabled = true
|
||
setupTopBar()
|
||
setupCounter()
|
||
setupBottomBar()
|
||
setupCaption()
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
// MARK: - Setup Top Bar
|
||
|
||
private func setupTopBar() {
|
||
topContainer.isUserInteractionEnabled = true
|
||
addSubview(topContainer)
|
||
|
||
backGlass.isCircle = true
|
||
backGlass.isUserInteractionEnabled = false
|
||
topContainer.addSubview(backGlass)
|
||
|
||
configureButton(backButton, icon: "chevron.left", size: 18, weight: .semibold, action: #selector(backTapped))
|
||
topContainer.addSubview(backButton)
|
||
|
||
menuGlass.isCircle = true
|
||
menuGlass.isUserInteractionEnabled = false
|
||
topContainer.addSubview(menuGlass)
|
||
|
||
let menuConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
|
||
menuButton.setImage(UIImage(systemName: "ellipsis", withConfiguration: menuConfig), for: .normal)
|
||
menuButton.tintColor = Self.primaryColor
|
||
menuButton.addTarget(self, action: #selector(menuTapped), for: .touchUpInside)
|
||
topContainer.addSubview(menuButton)
|
||
|
||
titleGlass.isUserInteractionEnabled = false
|
||
topContainer.addSubview(titleGlass)
|
||
|
||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||
titleLabel.textColor = Self.primaryColor
|
||
titleLabel.textAlignment = .center
|
||
titleLabel.lineBreakMode = .byTruncatingTail
|
||
topContainer.addSubview(titleLabel)
|
||
|
||
subtitleLabel.font = .systemFont(ofSize: 12)
|
||
subtitleLabel.textColor = Self.secondaryColor
|
||
subtitleLabel.textAlignment = .center
|
||
topContainer.addSubview(subtitleLabel)
|
||
}
|
||
|
||
// MARK: - Setup Counter
|
||
|
||
private func setupCounter() {
|
||
counterContainer.isUserInteractionEnabled = false
|
||
addSubview(counterContainer)
|
||
|
||
counterGlass.isUserInteractionEnabled = false
|
||
counterContainer.addSubview(counterGlass)
|
||
|
||
counterLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||
counterLabel.textColor = Self.primaryColor
|
||
counterLabel.textAlignment = .center
|
||
counterContainer.addSubview(counterLabel)
|
||
}
|
||
|
||
// MARK: - Setup Bottom Bar (Telegram parity)
|
||
|
||
private func setupBottomBar() {
|
||
bottomContainer.isUserInteractionEnabled = true
|
||
addSubview(bottomContainer)
|
||
|
||
// Left: Forward (single circle)
|
||
leftGlass.isCircle = true
|
||
leftGlass.isUserInteractionEnabled = false
|
||
bottomContainer.addSubview(leftGlass)
|
||
configureButton(forwardButton, icon: "arrowshape.turn.up.forward", size: 17, weight: .regular, action: #selector(forwardTapped))
|
||
bottomContainer.addSubview(forwardButton)
|
||
|
||
// Center: Draw + Captions (grouped capsule)
|
||
centerGlass.isUserInteractionEnabled = false
|
||
bottomContainer.addSubview(centerGlass)
|
||
|
||
configureButton(drawButton, icon: "pencil.tip.crop.circle", size: 17, weight: .regular, action: #selector(drawTapped))
|
||
bottomContainer.addSubview(drawButton)
|
||
|
||
centerSeparator.backgroundColor = Self.separatorColor
|
||
centerSeparator.isUserInteractionEnabled = false
|
||
bottomContainer.addSubview(centerSeparator)
|
||
|
||
configureButton(captionsButton, icon: "captions.bubble", size: 17, weight: .regular, action: #selector(captionsTapped))
|
||
bottomContainer.addSubview(captionsButton)
|
||
|
||
// Right: Delete (single circle)
|
||
rightGlass.isCircle = true
|
||
rightGlass.isUserInteractionEnabled = false
|
||
bottomContainer.addSubview(rightGlass)
|
||
configureButton(deleteButton, icon: "trash", size: 17, weight: .regular, action: #selector(deleteTapped))
|
||
bottomContainer.addSubview(deleteButton)
|
||
}
|
||
|
||
// MARK: - Setup Caption
|
||
|
||
private func setupCaption() {
|
||
captionContainer.isUserInteractionEnabled = false
|
||
captionContainer.isHidden = true
|
||
addSubview(captionContainer)
|
||
|
||
captionLabel.font = .systemFont(ofSize: 16)
|
||
captionLabel.textColor = Self.primaryColor
|
||
captionLabel.numberOfLines = 3
|
||
captionLabel.lineBreakMode = .byTruncatingTail
|
||
captionContainer.addSubview(captionLabel)
|
||
}
|
||
|
||
private func configureButton(_ button: UIButton, icon: String, size: CGFloat, weight: UIImage.SymbolWeight, action: Selector) {
|
||
let config = UIImage.SymbolConfiguration(pointSize: size, weight: weight)
|
||
button.setImage(UIImage(systemName: icon, withConfiguration: config), for: .normal)
|
||
button.tintColor = Self.primaryColor
|
||
button.addTarget(self, action: action, for: .touchUpInside)
|
||
}
|
||
|
||
// MARK: - Layout
|
||
|
||
override func layoutSubviews() {
|
||
super.layoutSubviews()
|
||
let sa = safeAreaInsets
|
||
let topY = sa.top > 0 ? sa.top : 20
|
||
|
||
layoutTopBar(topY: topY)
|
||
layoutCounter(topY: topY)
|
||
layoutBottomBar(sa: sa)
|
||
layoutCaption()
|
||
}
|
||
|
||
private func layoutTopBar(topY: CGFloat) {
|
||
let hPad: CGFloat = 16
|
||
topContainer.frame = CGRect(x: 0, y: topY, width: bounds.width, height: topButtonSize)
|
||
|
||
backGlass.frame = CGRect(x: hPad, y: 0, width: topButtonSize, height: topButtonSize)
|
||
backButton.frame = backGlass.frame
|
||
|
||
menuGlass.frame = CGRect(x: bounds.width - hPad - topButtonSize, y: 0, width: topButtonSize, height: topButtonSize)
|
||
menuButton.frame = menuGlass.frame
|
||
|
||
let titleMaxW = bounds.width - (hPad + topButtonSize + 12) * 2
|
||
let titleTextSize = titleLabel.sizeThatFits(CGSize(width: titleMaxW, height: 20))
|
||
let subtitleTextSize = subtitleLabel.sizeThatFits(CGSize(width: titleMaxW, height: 16))
|
||
let capsuleW = max(titleTextSize.width, subtitleTextSize.width) + 28
|
||
let capsuleX = (bounds.width - capsuleW) / 2
|
||
titleGlass.frame = CGRect(x: capsuleX, y: 0, width: capsuleW, height: topButtonSize)
|
||
titleLabel.frame = CGRect(x: capsuleX + 14, y: 5, width: capsuleW - 28, height: 20)
|
||
subtitleLabel.frame = CGRect(x: capsuleX + 14, y: 25, width: capsuleW - 28, height: 14)
|
||
}
|
||
|
||
private func layoutCounter(topY: CGFloat) {
|
||
let counterY = topY + topButtonSize + 6
|
||
let counterText = counterLabel.text ?? ""
|
||
let counterW = counterText.isEmpty ? 0 : counterLabel.sizeThatFits(CGSize(width: 200, height: 20)).width + 24
|
||
let counterH: CGFloat = 24
|
||
counterContainer.frame = CGRect(x: (bounds.width - counterW) / 2, y: counterY, width: counterW, height: counterH)
|
||
counterGlass.frame = counterContainer.bounds
|
||
counterLabel.frame = counterContainer.bounds
|
||
}
|
||
|
||
private func layoutBottomBar(sa: UIEdgeInsets) {
|
||
let hPad: CGFloat = 8
|
||
let bottomInset = sa.bottom > 0 ? sa.bottom : 16
|
||
let bottomY = bounds.height - bottomInset - bottomBarH
|
||
bottomContainer.frame = CGRect(x: 0, y: bottomY, width: bounds.width, height: bottomBarH)
|
||
|
||
// Left: Forward (single circle, 44×44)
|
||
leftGlass.frame = CGRect(x: hPad, y: 0, width: bottomBarH, height: bottomBarH)
|
||
forwardButton.frame = leftGlass.frame
|
||
|
||
// Center: Draw + Captions (grouped capsule, 88×44)
|
||
let centerW: CGFloat = bottomBarH * 2 // 88pt
|
||
let centerX = (bounds.width - centerW) / 2
|
||
centerGlass.frame = CGRect(x: centerX, y: 0, width: centerW, height: bottomBarH)
|
||
drawButton.frame = CGRect(x: centerX, y: 0, width: bottomBarH, height: bottomBarH)
|
||
captionsButton.frame = CGRect(x: centerX + bottomBarH, y: 0, width: bottomBarH, height: bottomBarH)
|
||
|
||
// Thin separator between Draw and Captions
|
||
let sepH: CGFloat = 24
|
||
centerSeparator.frame = CGRect(
|
||
x: centerX + bottomBarH - 0.25,
|
||
y: (bottomBarH - sepH) / 2,
|
||
width: 0.5,
|
||
height: sepH
|
||
)
|
||
|
||
// Right: Delete (single circle, 44×44)
|
||
rightGlass.frame = CGRect(x: bounds.width - hPad - bottomBarH, y: 0, width: bottomBarH, height: bottomBarH)
|
||
deleteButton.frame = rightGlass.frame
|
||
}
|
||
|
||
private func layoutCaption() {
|
||
guard !captionContainer.isHidden else { return }
|
||
let hPad: CGFloat = 16
|
||
let bottomGap: CGFloat = 14
|
||
let maxH: CGFloat = 80
|
||
|
||
let availW = bounds.width - hPad * 2
|
||
let textSize = captionLabel.sizeThatFits(CGSize(width: availW, height: maxH))
|
||
let captionH = min(textSize.height, maxH)
|
||
let captionY = bottomContainer.frame.minY - bottomGap - captionH
|
||
|
||
captionContainer.frame = CGRect(x: hPad, y: captionY, width: availW, height: captionH)
|
||
captionLabel.frame = captionContainer.bounds
|
||
}
|
||
|
||
// MARK: - Public API
|
||
|
||
func update(title: String, subtitle: String, counter: String, showCounter: Bool, caption: String) {
|
||
titleLabel.text = title
|
||
subtitleLabel.text = subtitle
|
||
counterLabel.text = counter
|
||
counterContainer.isHidden = !showCounter
|
||
captionLabel.text = caption
|
||
captionContainer.isHidden = caption.isEmpty
|
||
setNeedsLayout()
|
||
}
|
||
|
||
func menuButtonFrame(in coordinateSpace: UICoordinateSpace) -> CGRect {
|
||
menuButton.convert(menuButton.bounds, to: coordinateSpace)
|
||
}
|
||
|
||
func setControlsVisible(_ visible: Bool, animated: Bool) {
|
||
guard visible != isControlsVisible else { return }
|
||
isControlsVisible = visible
|
||
|
||
let block = { [self] in
|
||
topContainer.alpha = visible ? 1 : 0
|
||
counterContainer.alpha = visible ? 1 : 0
|
||
bottomContainer.alpha = visible ? 1 : 0
|
||
captionContainer.alpha = visible ? 1 : 0
|
||
}
|
||
|
||
if animated {
|
||
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut, animations: block)
|
||
} else {
|
||
block()
|
||
}
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
@objc private func backTapped() { onBack?() }
|
||
@objc private func menuTapped() { onMenu?() }
|
||
@objc private func forwardTapped() { onForward?() }
|
||
@objc private func drawTapped() { onDraw?() }
|
||
@objc private func captionsTapped() { onCaptions?() }
|
||
@objc private func deleteTapped() { onDelete?() }
|
||
|
||
// MARK: - Hit Test
|
||
|
||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||
let hit = super.hitTest(point, with: event)
|
||
return hit is UIButton ? hit : nil
|
||
}
|
||
}
|