миграция экрана Appearance на UIKit + обновление обоев из Android

This commit is contained in:
2026-04-15 19:27:12 +05:00
parent c43e83ab89
commit c1348aeb2f
6 changed files with 722 additions and 208 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -56,6 +56,15 @@ final class ChatDetailViewController: UIViewController {
private var titlePill: UIControl!
private var avatarButton: UIControl!
// MARK: - Wallpaper
private let wallpaperImageView: UIImageView = {
let iv = UIImageView()
iv.contentMode = .scaleAspectFill
iv.clipsToBounds = true
return iv
}()
// MARK: - Edge Effects
private let topEdgeEffectView = VariableBlurEdgeView(frame: .zero)
@@ -84,6 +93,7 @@ final class ChatDetailViewController: UIViewController {
super.viewDidLoad()
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
setupWallpaper()
setupMessageListController()
setupNavigationChrome()
setupEdgeEffects()
@@ -282,6 +292,44 @@ final class ChatDetailViewController: UIViewController {
)
}
// MARK: - Wallpaper
private func setupWallpaper() {
wallpaperImageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(wallpaperImageView)
NSLayoutConstraint.activate([
wallpaperImageView.topAnchor.constraint(equalTo: view.topAnchor),
wallpaperImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
wallpaperImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
wallpaperImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
updateWallpaperImage()
NotificationCenter.default.addObserver(
self,
selector: #selector(wallpaperDidChange),
name: UserDefaults.didChangeNotification,
object: nil
)
}
private func updateWallpaperImage() {
let wallpaperId = UserDefaults.standard.string(forKey: "rosetta_wallpaper_id") ?? "default"
let option = WallpaperOption.allOptions.first(where: { $0.id == wallpaperId })
?? WallpaperOption.allOptions[0]
switch option.style {
case .none:
wallpaperImageView.image = nil
case .image(let assetName):
wallpaperImageView.image = UIImage(named: assetName)
}
}
@objc private func wallpaperDidChange() {
updateWallpaperImage()
}
// MARK: - Edge Effects
private func setupEdgeEffects() {
@@ -337,6 +385,7 @@ final class ChatDetailViewController: UIViewController {
let isDark = traitCollection.userInterfaceStyle == .dark
topEdgeEffectView.setTintColor(isDark ? .black : .white)
updateBottomGradientColors()
updateWallpaperImage()
}
}

View File

@@ -203,9 +203,6 @@ final class ChatListRootViewController: UIViewController, UINavigationController
}
private func updateNavigationBlurHeight() {
// Telegram: edgeEffectHeight = componentHeight + 14 - searchBarExpansion
// Result: edge effect covers toolbar area + 14pt ONLY (not search bar).
// Search bar has its own background, doesn't need blur coverage.
navigationBlurHeightConstraint?.constant = max(0, headerTotalHeight + 14.0)
}
@@ -222,9 +219,10 @@ final class ChatListRootViewController: UIViewController, UINavigationController
editButtonControl.isUserInteractionEnabled = true
rightButtonsControl.isUserInteractionEnabled = true
toolbarTitleView.isUserInteractionEnabled = true
// Restore search bar position, blur, and tear down overlay
// Restore search bar position, blur, list, and tear down overlay
searchHeaderTopConstraint?.constant = headerBarHeight + searchTopSpacing
navigationBlurView.isHidden = false
listController.view.isHidden = false
teardownSearchOverlayImmediately()
}
}
@@ -309,9 +307,9 @@ final class ChatListRootViewController: UIViewController, UINavigationController
searchHeaderView.onActiveChanged = { [weak self] active in
guard let self else { return }
self.applySearchExpansion(1.0, animated: true)
// Hide blur when search active (search bar moves up, no blur needed)
self.updateNavigationBarBlur(progress: active ? 0.0 : (1.0 - self.lastSearchExpansion))
self.navigationBlurView.isHidden = active
self.listController.view.isHidden = active
self.updateNavigationBarBlur(progress: active ? 0.0 : (1.0 - self.lastSearchExpansion))
self.animateToolbarForSearch(active: active)
self.animateSearchBarPosition(active: active)
if active {
@@ -518,7 +516,7 @@ final class ChatListRootViewController: UIViewController, UINavigationController
view.insertSubview(bg, belowSubview: searchHeaderView)
NSLayoutConstraint.activate([
bg.topAnchor.constraint(equalTo: view.topAnchor),
bg.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing),
bg.leadingAnchor.constraint(equalTo: view.leadingAnchor),
bg.trailingAnchor.constraint(equalTo: view.trailingAnchor),
bg.bottomAnchor.constraint(equalTo: view.bottomAnchor),
@@ -534,25 +532,12 @@ final class ChatListRootViewController: UIViewController, UINavigationController
hosting.didMove(toParent: self)
NSLayoutConstraint.activate([
// Content starts below search bar (not behind it)
hosting.view.topAnchor.constraint(equalTo: searchHeaderView.bottomAnchor, constant: searchBottomSpacing),
hosting.view.topAnchor.constraint(equalTo: bg.topAnchor),
hosting.view.leadingAnchor.constraint(equalTo: bg.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: bg.trailingAnchor),
hosting.view.bottomAnchor.constraint(equalTo: bg.bottomAnchor),
])
// Edge fade gradient below search bar (Telegram-style)
let edgeFade = CAGradientLayer()
edgeFade.colors = [
UIColor(RosettaColors.Adaptive.background).cgColor,
UIColor(RosettaColors.Adaptive.background).withAlphaComponent(0).cgColor,
]
edgeFade.locations = [0, 1]
edgeFade.startPoint = CGPoint(x: 0.5, y: 0)
edgeFade.endPoint = CGPoint(x: 0.5, y: 1)
edgeFade.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 14)
hosting.view.layer.addSublayer(edgeFade)
searchOverlayView = bg
searchContentHosting = hosting
@@ -1261,8 +1246,8 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
// Telegram-style circular X button: 44pt, glass material
cancelButton.translatesAutoresizingMaskIntoConstraints = false
let xConfig = UIImage.SymbolConfiguration(pointSize: 16, weight: .medium)
cancelButton.setImage(UIImage(systemName: "xmark", withConfiguration: xConfig), for: .normal)
// Telegram exact: custom drawn X 2pt lineWidth, round cap, 40×40 canvas
cancelButton.setImage(Self.telegramCloseIcon(size: 40, color: .white), for: .normal)
cancelButton.setTitle(nil, for: .normal)
cancelButton.clipsToBounds = false
cancelButton.backgroundColor = .clear
@@ -1408,6 +1393,24 @@ private final class ChatListSearchHeaderView: UIView, UITextFieldDelegate {
inlineClearButton.isUserInteractionEnabled = inlineClearButton.alpha > 0
}
/// Telegram exact close icon: two diagonal lines, 2pt width, round cap.
/// Source: SearchBarPlaceholderNode.swift lines from (12,12)(28,28) in 40×40 canvas.
private static func telegramCloseIcon(size: CGFloat, color: UIColor) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size))
return renderer.image { ctx in
let cg = ctx.cgContext
cg.setStrokeColor(color.cgColor)
cg.setLineWidth(2.0)
cg.setLineCap(.round)
let inset: CGFloat = size * 0.3 // 12/40 = 0.3
cg.move(to: CGPoint(x: inset, y: inset))
cg.addLine(to: CGPoint(x: size - inset, y: size - inset))
cg.move(to: CGPoint(x: size - inset, y: inset))
cg.addLine(to: CGPoint(x: inset, y: size - inset))
cg.strokePath()
}.withRenderingMode(.alwaysTemplate)
}
@objc private func handleCapsuleTapped() {
guard !isSearchActive else { return }
setSearchActive(true, animated: true)
@@ -1614,7 +1617,7 @@ private final class ChatListToolbarDualActionButton: UIView {
private let backgroundView = ChatListToolbarGlassCapsuleView()
private let addButton = UIButton(type: .system)
private let composeButton = UIButton(type: .system)
private let dividerView = UIView()
// dividerView removed per Telegram parity
override init(frame: CGRect) {
super.init(frame: frame)
@@ -1626,18 +1629,18 @@ private final class ChatListToolbarDualActionButton: UIView {
addButton.translatesAutoresizingMaskIntoConstraints = false
composeButton.translatesAutoresizingMaskIntoConstraints = false
dividerView.translatesAutoresizingMaskIntoConstraints = false
addButton.tintColor = UIColor(RosettaColors.Adaptive.text)
composeButton.tintColor = UIColor(RosettaColors.Adaptive.text)
addButton.accessibilityLabel = "Add"
composeButton.accessibilityLabel = "Compose"
let iconConfig = UIImage.SymbolConfiguration(pointSize: 18, weight: .medium)
let addIcon = UIImage(named: "toolbar-add-chat")?.withRenderingMode(.alwaysTemplate)
?? UIImage(systemName: "plus", withConfiguration: iconConfig)
let composeIcon = UIImage(named: "toolbar-compose")?.withRenderingMode(.alwaysTemplate)
?? UIImage(systemName: "square.and.pencil", withConfiguration: iconConfig)
let iconConfig = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)
let targetIconSize = CGSize(width: 19, height: 19)
let addIcon = (UIImage(named: "toolbar-add-chat")?.withRenderingMode(.alwaysTemplate)
?? UIImage(systemName: "plus", withConfiguration: iconConfig))?.scaledTo(targetIconSize)
let composeIcon = (UIImage(named: "toolbar-compose")?.withRenderingMode(.alwaysTemplate)
?? UIImage(systemName: "square.and.pencil", withConfiguration: iconConfig))?.scaledTo(targetIconSize)
addButton.setImage(addIcon, for: .normal)
composeButton.setImage(composeIcon, for: .normal)
@@ -1648,11 +1651,8 @@ private final class ChatListToolbarDualActionButton: UIView {
addButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit])
composeButton.addTarget(self, action: #selector(handleTouchUp(_:)), for: [.touchUpInside, .touchCancel, .touchDragExit])
dividerView.backgroundColor = UIColor.white.withAlphaComponent(0.16)
addSubview(addButton)
addSubview(composeButton)
addSubview(dividerView)
NSLayoutConstraint.activate([
backgroundView.topAnchor.constraint(equalTo: topAnchor),
@@ -1663,20 +1663,15 @@ private final class ChatListToolbarDualActionButton: UIView {
addButton.leadingAnchor.constraint(equalTo: leadingAnchor),
addButton.topAnchor.constraint(equalTo: topAnchor),
addButton.bottomAnchor.constraint(equalTo: bottomAnchor),
addButton.widthAnchor.constraint(equalToConstant: 44),
addButton.widthAnchor.constraint(equalToConstant: 36),
composeButton.trailingAnchor.constraint(equalTo: trailingAnchor),
composeButton.topAnchor.constraint(equalTo: topAnchor),
composeButton.bottomAnchor.constraint(equalTo: bottomAnchor),
composeButton.widthAnchor.constraint(equalToConstant: 44),
dividerView.centerXAnchor.constraint(equalTo: centerXAnchor),
dividerView.centerYAnchor.constraint(equalTo: centerYAnchor),
dividerView.widthAnchor.constraint(equalToConstant: 1.0 / UIScreen.main.scale),
dividerView.heightAnchor.constraint(equalToConstant: 20),
composeButton.widthAnchor.constraint(equalToConstant: 36),
heightAnchor.constraint(equalToConstant: 44),
widthAnchor.constraint(equalToConstant: 88),
widthAnchor.constraint(equalToConstant: 72),
])
self.frame = CGRect(origin: .zero, size: intrinsicContentSize)
@@ -1687,7 +1682,7 @@ private final class ChatListToolbarDualActionButton: UIView {
}
override var intrinsicContentSize: CGSize {
CGSize(width: 88, height: 44)
CGSize(width: 72, height: 44)
}
@objc private func handleAddTapped() {
@@ -1898,3 +1893,12 @@ private final class ChatListToolbarArcSpinnerView: UIView {
layer.removeAnimation(forKey: animationKey)
}
}
private extension UIImage {
func scaledTo(_ size: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
self.draw(in: CGRect(origin: .zero, size: size))
}.withRenderingMode(renderingMode)
}
}

View File

@@ -1,3 +1,4 @@
import UIKit
import SwiftUI
// MARK: - Wallpaper Definitions
@@ -50,137 +51,364 @@ enum ThemeMode: String, CaseIterable {
}
}
// MARK: - Appearance View
// MARK: - Appearance ViewController
struct AppearanceView: View {
@AppStorage("rosetta_wallpaper_id") private var selectedWallpaperId: String = "default"
@AppStorage("rosetta_theme_mode") private var themeModeRaw: String = "dark"
final class AppearanceViewController: UIViewController {
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
// Header
private let headerBarHeight: CGFloat = 44
private var backButton: UIControl!
private let titleLabel = UILabel()
// Chat preview
private let previewContainer = UIView()
private let previewWallpaperView = UIImageView()
private let incomingBubble = UIView()
private let outgoingBubble = UIView()
// Theme section
private let themeSectionLabel = UILabel()
private let themeCard = UIView()
private var themeButtons: [ThemeMode: UIButton] = [:]
private var themeDividers: [UIView] = []
// Wallpaper section
private let wallpaperSectionLabel = UILabel()
private var wallpaperCells: [String: WallpaperCellView] = [:]
// State
private var selectedWallpaperId: String {
get { UserDefaults.standard.string(forKey: "rosetta_wallpaper_id") ?? "default" }
set { UserDefaults.standard.set(newValue, forKey: "rosetta_wallpaper_id") }
}
private var themeModeRaw: String {
get { UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "dark" }
set { UserDefaults.standard.set(newValue, forKey: "rosetta_theme_mode") }
}
private var themeMode: ThemeMode {
ThemeMode(rawValue: themeModeRaw) ?? .dark
}
private let columns = Array(repeating: GridItem(.flexible(), spacing: 10), count: 3)
// MARK: - Init
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 24) {
chatPreviewSection
themeSection
wallpaperSection
init() {
super.init(nibName: nil, bundle: nil)
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 100)
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
navigationController?.setNavigationBarHidden(true, animated: false)
setupScrollView()
setupHeader()
setupChatPreview()
setupThemeSection()
setupWallpaperSection()
updateSelection()
}
.background(RosettaColors.Adaptive.background)
.scrollContentBackground(.hidden)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Text("Appearance")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
setupFullWidthSwipeBack()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutHeader()
layoutThemeDividers()
}
private func layoutThemeDividers() {
let w = themeCard.bounds.width
let h = themeCard.bounds.height
let dividerH: CGFloat = 24
let y = (h - dividerH) / 2
for (i, divider) in themeDividers.enumerated() {
let x = w * CGFloat(i + 1) / 3.0
divider.frame = CGRect(x: x, y: y, width: 0.5, height: dividerH)
}
}
.toolbarBackground(.hidden, for: .navigationBar)
// MARK: - Full-Width Swipe Back
private var addedSwipeBackGesture = false
private func setupFullWidthSwipeBack() {
guard !addedSwipeBackGesture else { return }
addedSwipeBackGesture = true
guard let nav = navigationController,
let edgeGesture = nav.interactivePopGestureRecognizer,
let targets = edgeGesture.value(forKey: "targets") as? NSArray,
targets.count > 0 else { return }
edgeGesture.isEnabled = true
let fullWidthGesture = UIPanGestureRecognizer()
fullWidthGesture.setValue(targets, forKey: "targets")
fullWidthGesture.delegate = self
nav.view.addGestureRecognizer(fullWidthGesture)
}
// MARK: - Header
private func setupHeader() {
let back = AppearanceBackButton()
back.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
back.layer.zPosition = 55
view.addSubview(back)
backButton = back
titleLabel.text = "Appearance"
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
titleLabel.textColor = UIColor(RosettaColors.Adaptive.text)
titleLabel.textAlignment = .center
titleLabel.layer.zPosition = 55
view.addSubview(titleLabel)
}
private func layoutHeader() {
let safeTop = view.safeAreaInsets.top
let centerY = safeTop + headerBarHeight * 0.5
let sideMargin: CGFloat = 8
let backSize: CGFloat = 44
backButton.frame = CGRect(
x: sideMargin,
y: centerY - backSize * 0.5,
width: backSize,
height: backSize
)
let titleWidth: CGFloat = 200
titleLabel.frame = CGRect(
x: (view.bounds.width - titleWidth) * 0.5,
y: centerY - 22,
width: titleWidth,
height: 44
)
// Update scroll inset
let headerTotal = safeTop + headerBarHeight + 8
scrollView.contentInset.top = headerTotal
scrollView.verticalScrollIndicatorInsets.top = headerTotal
}
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
// MARK: - Scroll View
private func setupScrollView() {
scrollView.showsVerticalScrollIndicator = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
contentStack.axis = .vertical
contentStack.spacing = 24
contentStack.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(contentStack)
NSLayoutConstraint.activate([
contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
contentStack.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor, constant: 16),
contentStack.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor, constant: -16),
contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -100),
])
}
// MARK: - Chat Preview
private var chatPreviewSection: some View {
VStack(spacing: 0) {
ZStack {
// Wallpaper background
wallpaperPreview(for: selectedWallpaperId)
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
private func setupChatPreview() {
previewContainer.layer.cornerRadius = 20
previewContainer.layer.cornerCurve = .continuous
previewContainer.clipsToBounds = true
// Sample messages overlay
VStack(spacing: 6) {
Spacer()
previewWallpaperView.contentMode = .scaleAspectFill
previewWallpaperView.clipsToBounds = true
previewWallpaperView.translatesAutoresizingMaskIntoConstraints = false
previewContainer.addSubview(previewWallpaperView)
NSLayoutConstraint.activate([
previewWallpaperView.topAnchor.constraint(equalTo: previewContainer.topAnchor),
previewWallpaperView.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor),
previewWallpaperView.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor),
previewWallpaperView.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor),
])
// Incoming message
HStack {
Text("Hey! How's it going?")
.font(.system(size: 15))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(hex: 0x2A2A2A))
)
Spacer()
}
// Incoming bubble
let inLabel = UILabel()
inLabel.text = "Hey! How's it going?"
inLabel.font = .systemFont(ofSize: 15)
inLabel.textColor = .white
inLabel.translatesAutoresizingMaskIntoConstraints = false
// Outgoing message
HStack {
Spacer()
Text("Great, thanks!")
.font(.system(size: 15))
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(RosettaColors.primaryBlue)
)
}
incomingBubble.backgroundColor = UIColor(red: 42/255, green: 42/255, blue: 42/255, alpha: 1)
incomingBubble.layer.cornerRadius = 18
incomingBubble.layer.cornerCurve = .continuous
incomingBubble.translatesAutoresizingMaskIntoConstraints = false
incomingBubble.addSubview(inLabel)
NSLayoutConstraint.activate([
inLabel.topAnchor.constraint(equalTo: incomingBubble.topAnchor, constant: 8),
inLabel.leadingAnchor.constraint(equalTo: incomingBubble.leadingAnchor, constant: 14),
inLabel.trailingAnchor.constraint(equalTo: incomingBubble.trailingAnchor, constant: -14),
inLabel.bottomAnchor.constraint(equalTo: incomingBubble.bottomAnchor, constant: -8),
])
previewContainer.addSubview(incomingBubble)
Spacer()
.frame(height: 12)
}
.padding(.horizontal, 12)
}
}
// Outgoing bubble
let outLabel = UILabel()
outLabel.text = "Great, thanks!"
outLabel.font = .systemFont(ofSize: 15)
outLabel.textColor = .white
outLabel.translatesAutoresizingMaskIntoConstraints = false
outgoingBubble.backgroundColor = UIColor(RosettaColors.primaryBlue)
outgoingBubble.layer.cornerRadius = 18
outgoingBubble.layer.cornerCurve = .continuous
outgoingBubble.translatesAutoresizingMaskIntoConstraints = false
outgoingBubble.addSubview(outLabel)
NSLayoutConstraint.activate([
outLabel.topAnchor.constraint(equalTo: outgoingBubble.topAnchor, constant: 8),
outLabel.leadingAnchor.constraint(equalTo: outgoingBubble.leadingAnchor, constant: 14),
outLabel.trailingAnchor.constraint(equalTo: outgoingBubble.trailingAnchor, constant: -14),
outLabel.bottomAnchor.constraint(equalTo: outgoingBubble.bottomAnchor, constant: -8),
])
previewContainer.addSubview(outgoingBubble)
// Layout bubbles
NSLayoutConstraint.activate([
incomingBubble.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
incomingBubble.bottomAnchor.constraint(equalTo: outgoingBubble.topAnchor, constant: -6),
outgoingBubble.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
outgoingBubble.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12),
])
previewContainer.translatesAutoresizingMaskIntoConstraints = false
contentStack.addArrangedSubview(previewContainer)
previewContainer.heightAnchor.constraint(equalToConstant: 200).isActive = true
}
// MARK: - Theme Section
private var themeSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("COLOR THEME")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.padding(.horizontal, 4)
private func setupThemeSection() {
themeSectionLabel.text = "COLOR THEME"
themeSectionLabel.font = .systemFont(ofSize: 13, weight: .medium)
themeSectionLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary)
SettingsCard {
HStack(spacing: 0) {
ForEach(ThemeMode.allCases, id: \.self) { mode in
themeButton(mode)
if mode != ThemeMode.allCases.last {
Divider()
.frame(height: 24)
.foregroundStyle(RosettaColors.Adaptive.divider)
}
}
}
.frame(height: 52)
}
let sectionStack = UIStackView()
sectionStack.axis = .vertical
sectionStack.spacing = 10
let labelWrapper = UIView()
themeSectionLabel.translatesAutoresizingMaskIntoConstraints = false
labelWrapper.addSubview(themeSectionLabel)
NSLayoutConstraint.activate([
themeSectionLabel.leadingAnchor.constraint(equalTo: labelWrapper.leadingAnchor, constant: 4),
themeSectionLabel.topAnchor.constraint(equalTo: labelWrapper.topAnchor),
themeSectionLabel.bottomAnchor.constraint(equalTo: labelWrapper.bottomAnchor),
])
sectionStack.addArrangedSubview(labelWrapper)
// Card
themeCard.backgroundColor = UIColor { $0.userInterfaceStyle == .dark
? UIColor(red: 28/255, green: 28/255, blue: 30/255, alpha: 1)
: UIColor(red: 242/255, green: 242/255, blue: 247/255, alpha: 1)
}
themeCard.layer.cornerRadius = 26
themeCard.layer.cornerCurve = .continuous
let buttonStack = UIStackView()
buttonStack.axis = .horizontal
buttonStack.distribution = .fillEqually
buttonStack.translatesAutoresizingMaskIntoConstraints = false
themeCard.addSubview(buttonStack)
NSLayoutConstraint.activate([
buttonStack.topAnchor.constraint(equalTo: themeCard.topAnchor),
buttonStack.leadingAnchor.constraint(equalTo: themeCard.leadingAnchor),
buttonStack.trailingAnchor.constraint(equalTo: themeCard.trailingAnchor),
buttonStack.bottomAnchor.constraint(equalTo: themeCard.bottomAnchor),
])
for (index, mode) in ThemeMode.allCases.enumerated() {
let btn = UIButton(type: .system)
btn.tag = index
btn.addTarget(self, action: #selector(themeButtonTapped(_:)), for: .touchUpInside)
var config = UIButton.Configuration.plain()
config.image = UIImage(systemName: mode.iconName)?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 14)
)
config.title = mode.label
config.imagePadding = 8
btn.configuration = config
btn.tintColor = UIColor(RosettaColors.Adaptive.textSecondary)
themeButtons[mode] = btn
buttonStack.addArrangedSubview(btn)
}
private func themeButton(_ mode: ThemeMode) -> some View {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
// Dividers added to themeCard directly (not the stack), positioned in layoutSubviews
for _ in 0..<2 {
let divider = UIView()
divider.backgroundColor = UIColor(RosettaColors.Adaptive.divider)
themeCard.addSubview(divider)
themeDividers.append(divider)
}
themeCard.translatesAutoresizingMaskIntoConstraints = false
themeCard.heightAnchor.constraint(equalToConstant: 52).isActive = true
sectionStack.addArrangedSubview(themeCard)
contentStack.addArrangedSubview(sectionStack)
}
@objc private func themeButtonTapped(_ sender: UIButton) {
let mode = ThemeMode.allCases[sender.tag]
themeModeRaw = mode.rawValue
}
updateThemeButtons()
applyThemeMode(mode)
} label: {
HStack(spacing: 8) {
Image(systemName: mode.iconName)
.font(.system(size: 14))
.foregroundStyle(themeMode == mode ? RosettaColors.primaryBlue : RosettaColors.Adaptive.textSecondary)
}
Text(mode.label)
.font(.system(size: 15, weight: themeMode == mode ? .semibold : .regular))
.foregroundStyle(themeMode == mode ? RosettaColors.primaryBlue : RosettaColors.Adaptive.text)
private func updateThemeButtons() {
let current = themeMode
let blue = UIColor(RosettaColors.primaryBlue)
let secondary = UIColor(RosettaColors.Adaptive.textSecondary)
let text = UIColor(RosettaColors.Adaptive.text)
for mode in ThemeMode.allCases {
guard let btn = themeButtons[mode] else { continue }
let isActive = mode == current
btn.tintColor = isActive ? blue : secondary
var config = btn.configuration ?? .plain()
var titleAttr = AttributeContainer()
titleAttr.font = .systemFont(ofSize: 15, weight: isActive ? .semibold : .regular)
titleAttr.foregroundColor = isActive ? blue : text
config.attributedTitle = AttributedString(mode.label, attributes: titleAttr)
btn.configuration = config
}
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private func applyThemeMode(_ mode: ThemeMode) {
@@ -205,79 +433,313 @@ struct AppearanceView: View {
// MARK: - Wallpaper Section
private var wallpaperSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("CHAT BACKGROUND")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.padding(.horizontal, 4)
private func setupWallpaperSection() {
wallpaperSectionLabel.text = "CHAT BACKGROUND"
wallpaperSectionLabel.font = .systemFont(ofSize: 13, weight: .medium)
wallpaperSectionLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary)
LazyVGrid(columns: columns, spacing: 10) {
ForEach(WallpaperOption.allOptions) { option in
wallpaperCell(option)
}
let sectionStack = UIStackView()
sectionStack.axis = .vertical
sectionStack.spacing = 10
let labelWrapper = UIView()
wallpaperSectionLabel.translatesAutoresizingMaskIntoConstraints = false
labelWrapper.addSubview(wallpaperSectionLabel)
NSLayoutConstraint.activate([
wallpaperSectionLabel.leadingAnchor.constraint(equalTo: labelWrapper.leadingAnchor, constant: 4),
wallpaperSectionLabel.topAnchor.constraint(equalTo: labelWrapper.topAnchor),
wallpaperSectionLabel.bottomAnchor.constraint(equalTo: labelWrapper.bottomAnchor),
])
sectionStack.addArrangedSubview(labelWrapper)
// Grid: 3 columns
let options = WallpaperOption.allOptions
let columns = 3
let rows = Int(ceil(Double(options.count) / Double(columns)))
let gridStack = UIStackView()
gridStack.axis = .vertical
gridStack.spacing = 10
for row in 0..<rows {
let rowStack = UIStackView()
rowStack.axis = .horizontal
rowStack.spacing = 10
rowStack.distribution = .fillEqually
for col in 0..<columns {
let idx = row * columns + col
if idx < options.count {
let option = options[idx]
let cell = WallpaperCellView(option: option)
cell.onTap = { [weak self] in
self?.selectWallpaper(option.id)
}
cell.heightAnchor.constraint(equalToConstant: 140).isActive = true
rowStack.addArrangedSubview(cell)
wallpaperCells[option.id] = cell
} else {
let spacer = UIView()
rowStack.addArrangedSubview(spacer)
}
}
private func wallpaperCell(_ option: WallpaperOption) -> some View {
let isSelected = selectedWallpaperId == option.id
return Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedWallpaperId = option.id
}
} label: {
ZStack {
wallpaperPreview(for: option.id)
// "None" label
if case .none = option.style {
Text("None")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
gridStack.addArrangedSubview(rowStack)
}
// Selection checkmark
if isSelected {
VStack {
Spacer()
HStack {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 22))
.foregroundStyle(RosettaColors.primaryBlue)
.background(Circle().fill(.white).frame(width: 18, height: 18))
.padding(8)
}
}
}
}
.frame(height: 140)
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(isSelected ? RosettaColors.primaryBlue : Color.white.opacity(0.1), lineWidth: isSelected ? 2 : 0.5)
)
}
.buttonStyle(.plain)
sectionStack.addArrangedSubview(gridStack)
contentStack.addArrangedSubview(sectionStack)
}
// MARK: - Wallpaper Preview Renderer
private func selectWallpaper(_ id: String) {
selectedWallpaperId = id
updateSelection()
}
@ViewBuilder
private func wallpaperPreview(for wallpaperId: String) -> some View {
let option = WallpaperOption.allOptions.first(where: { $0.id == wallpaperId })
// MARK: - Update State
private func updateSelection() {
let selected = selectedWallpaperId
// Update wallpaper cells
for (id, cell) in wallpaperCells {
cell.setSelected(id == selected)
}
// Update preview
let option = WallpaperOption.allOptions.first(where: { $0.id == selected })
?? WallpaperOption.allOptions[0]
switch option.style {
case .none:
RosettaColors.Adaptive.background
previewWallpaperView.image = nil
previewContainer.backgroundColor = UIColor(RosettaColors.Adaptive.background)
case .image(let assetName):
Image(assetName)
.resizable()
.aspectRatio(contentMode: .fill)
previewWallpaperView.image = UIImage(named: assetName)
previewContainer.backgroundColor = .clear
}
// Update theme buttons
updateThemeButtons()
}
// MARK: - Trait Changes
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
updateSelection()
}
}
}
// MARK: - UIGestureRecognizerDelegate
extension AppearanceViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return false }
let velocity = pan.velocity(in: pan.view)
return velocity.x > 0 && abs(velocity.x) > abs(velocity.y)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool { false }
}
// MARK: - Back Button (glass circle + chevron)
private final class AppearanceBackButton: UIControl {
private let glassView = TelegramGlassUIView(frame: .zero)
private let chevronLayer = CAShapeLayer()
private static let viewBox = CGSize(width: 10.7, height: 19.63)
override init(frame: CGRect) {
super.init(frame: frame)
glassView.isUserInteractionEnabled = false
addSubview(glassView)
chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor
layer.addSublayer(chevronLayer)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: CGSize { CGSize(width: 44, height: 44) }
override func layoutSubviews() {
super.layoutSubviews()
glassView.frame = bounds
glassView.fixedCornerRadius = bounds.height * 0.5
glassView.updateGlass()
guard bounds.width > 0 else { return }
let iconSize = CGSize(width: 11, height: 20)
let origin = CGPoint(
x: (bounds.width - iconSize.width) / 2,
y: (bounds.height - iconSize.height) / 2
)
var parser = SVGPathParser(pathData: TelegramIconPath.backChevron)
let rawPath = parser.parse()
let vb = Self.viewBox
var transform = CGAffineTransform(translationX: origin.x, y: origin.y)
.scaledBy(x: iconSize.width / vb.width, y: iconSize.height / vb.height)
chevronLayer.path = rawPath.copy(using: &transform)
chevronLayer.frame = bounds
}
override var isHighlighted: Bool {
didSet { alpha = isHighlighted ? 0.6 : 1.0 }
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
chevronLayer.fillColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }.cgColor
}
}
}
// MARK: - Wallpaper Cell View
private final class WallpaperCellView: UIView {
var onTap: (() -> Void)?
private let imageView = UIImageView()
private let noneLabel = UILabel()
private let checkmarkView = UIImageView()
private let borderLayer = CAShapeLayer()
private let option: WallpaperOption
init(option: WallpaperOption) {
self.option = option
super.init(frame: .zero)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
private func setupUI() {
layer.cornerRadius = 14
layer.cornerCurve = .continuous
clipsToBounds = true
// Wallpaper image
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(imageView)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: topAnchor),
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
switch option.style {
case .none:
imageView.image = nil
backgroundColor = UIColor(RosettaColors.Adaptive.background)
noneLabel.text = "None"
noneLabel.font = .systemFont(ofSize: 13, weight: .medium)
noneLabel.textColor = UIColor(RosettaColors.Adaptive.textSecondary)
noneLabel.textAlignment = .center
noneLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(noneLabel)
NSLayoutConstraint.activate([
noneLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
noneLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
])
case .image(let assetName):
imageView.image = UIImage(named: assetName)
}
// Checkmark
let config = UIImage.SymbolConfiguration(pointSize: 22)
checkmarkView.image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: config)
checkmarkView.tintColor = UIColor(RosettaColors.primaryBlue)
checkmarkView.isHidden = true
checkmarkView.translatesAutoresizingMaskIntoConstraints = false
let checkBg = UIView()
checkBg.backgroundColor = .white
checkBg.layer.cornerRadius = 9
checkBg.translatesAutoresizingMaskIntoConstraints = false
addSubview(checkBg)
addSubview(checkmarkView)
NSLayoutConstraint.activate([
checkmarkView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
checkmarkView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10),
checkmarkView.widthAnchor.constraint(equalToConstant: 22),
checkmarkView.heightAnchor.constraint(equalToConstant: 22),
checkBg.centerXAnchor.constraint(equalTo: checkmarkView.centerXAnchor),
checkBg.centerYAnchor.constraint(equalTo: checkmarkView.centerYAnchor),
checkBg.widthAnchor.constraint(equalToConstant: 18),
checkBg.heightAnchor.constraint(equalToConstant: 18),
])
self.checkmarkBackground = checkBg
// Border
borderLayer.fillColor = nil
borderLayer.lineWidth = 0.5
borderLayer.strokeColor = UIColor.white.withAlphaComponent(0.1).cgColor
layer.addSublayer(borderLayer)
// Tap
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tap)
}
private var checkmarkBackground: UIView!
@objc private func handleTap() {
onTap?()
}
func setSelected(_ selected: Bool) {
checkmarkView.isHidden = !selected
checkmarkBackground.isHidden = !selected
let blue = UIColor(RosettaColors.primaryBlue)
borderLayer.strokeColor = selected
? blue.cgColor
: UIColor.white.withAlphaComponent(0.1).cgColor
borderLayer.lineWidth = selected ? 2 : 0.5
}
override func layoutSubviews() {
super.layoutSubviews()
let path = UIBezierPath(
roundedRect: bounds.insetBy(dx: borderLayer.lineWidth / 2, dy: borderLayer.lineWidth / 2),
cornerRadius: 14
)
borderLayer.path = path.cgPath
borderLayer.frame = bounds
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
if case .image(let assetName) = option.style {
imageView.image = UIImage(named: assetName)
}
}
}
}
// MARK: - SwiftUI Bridge (for legacy SettingsView navigation path)
struct AppearanceViewRepresentable: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> AppearanceViewController {
AppearanceViewController()
}
func updateUIViewController(_ uiViewController: AppearanceViewController, context: Context) {}
}

View File

@@ -63,7 +63,7 @@ struct SettingsView: View {
case .backup:
BackupView()
case .appearance:
AppearanceView()
AppearanceViewRepresentable()
}
}
.task {

View File

@@ -543,10 +543,9 @@ final class SettingsViewController: UIViewController, UIScrollViewDelegate {
}
@objc private func appearanceTapped() {
let hosting = UIHostingController(rootView: AppearanceView())
hosting.view.backgroundColor = UIColor(RosettaColors.Adaptive.background)
let vc = AppearanceViewController()
onDetailStateChanged?(true)
navigationController?.pushViewController(hosting, animated: true)
navigationController?.pushViewController(vc, animated: true)
}
@objc private func updatesTapped() {