ref(ios): native tab to use app icons and avatars

This commit is contained in:
tapframe 2026-05-06 19:39:06 +05:30
parent 8b2a635174
commit 38a786850c
6 changed files with 477 additions and 8 deletions

View file

@ -7,3 +7,12 @@ internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Un
internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit
internal actual fun publishNativeSelectedTab(tabName: String) = Unit
internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit
internal actual fun publishNativeProfileTabIcon(
name: String?,
avatarColorHex: String?,
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
) = Unit

View file

@ -127,11 +127,13 @@ import com.nuvio.app.features.player.PlayerRoute
import com.nuvio.app.features.player.PlayerScreen
import com.nuvio.app.features.player.sanitizePlaybackHeaders
import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders
import com.nuvio.app.features.profiles.AvatarRepository
import com.nuvio.app.features.profiles.NuvioProfile
import com.nuvio.app.features.profiles.ProfileEditScreen
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.profiles.ProfileSelectionScreen
import com.nuvio.app.features.profiles.ProfileSwitcherTab
import com.nuvio.app.features.profiles.avatarStorageUrl
import com.nuvio.app.features.search.SearchScreen
import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen
@ -316,9 +318,33 @@ fun App() {
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
val networkStatusUiState by remember {
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
LaunchedEffect(
profileState.activeProfile?.profileIndex,
profileState.activeProfile?.name,
profileState.activeProfile?.avatarColorHex,
profileState.activeProfile?.avatarId,
profileAvatars,
) {
val activeProfile = profileState.activeProfile
val avatarItem = activeProfile?.avatarId?.let { avatarId ->
profileAvatars.find { it.id == avatarId }
}
NativeTabBridge.publishProfileTabIcon(
name = activeProfile?.name,
avatarColorHex = activeProfile?.avatarColorHex,
avatarImageUrl = avatarItem
?.storagePath
?.takeIf { it.isNotBlank() }
?.let(::avatarStorageUrl),
avatarBackgroundColorHex = avatarItem?.bgColor,
)
}
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var isNewProfile by remember { mutableStateOf(false) }

View file

@ -36,6 +36,24 @@ internal object NativeTabBridge {
fun publishLiquidGlassEnabled(enabled: Boolean) {
publishLiquidGlassNativeTabBarEnabled(enabled && isLiquidGlassNativeTabBarSupported())
}
fun publishAccentColor(hexColor: String) {
publishNativeTabAccentColor(hexColor)
}
fun publishProfileTabIcon(
name: String?,
avatarColorHex: String?,
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
) {
publishNativeProfileTabIcon(
name = name,
avatarColorHex = avatarColorHex,
avatarImageUrl = avatarImageUrl,
avatarBackgroundColorHex = avatarBackgroundColorHex,
)
}
}
fun nativeTabSelect(tabName: String) {
@ -49,3 +67,12 @@ internal expect fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean)
internal expect fun publishNativeTabBarVisible(visible: Boolean)
internal expect fun publishNativeSelectedTab(tabName: String)
internal expect fun publishNativeTabAccentColor(hexColor: String)
internal expect fun publishNativeProfileTabIcon(
name: String?,
avatarColorHex: String?,
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
)

View file

@ -35,6 +35,7 @@ object ThemeSettingsRepository {
_selectedTheme.value = AppTheme.WHITE
_amoledEnabled.value = false
_liquidGlassNativeTabBarEnabled.value = false
NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex())
NativeTabBridge.publishLiquidGlassEnabled(false)
_selectedAppLanguage.value = AppLanguage.ENGLISH
}
@ -52,6 +53,7 @@ object ThemeSettingsRepository {
AppTheme.WHITE
}
_selectedTheme.value = theme
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
_amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false
val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false
_liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled
@ -66,6 +68,7 @@ object ThemeSettingsRepository {
if (_selectedTheme.value == theme) return
_selectedTheme.value = theme
ThemeSettingsStorage.saveSelectedTheme(theme.name)
NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex())
}
fun setAmoled(enabled: Boolean) {
@ -91,3 +94,13 @@ object ThemeSettingsRepository {
_selectedAppLanguage.value = language
}
}
private fun AppTheme.nativeTabAccentHex(): String = when (this) {
AppTheme.CRIMSON -> "#E53935"
AppTheme.OCEAN -> "#1E88E5"
AppTheme.VIOLET -> "#8E24AA"
AppTheme.EMERALD -> "#43A047"
AppTheme.AMBER -> "#FB8C00"
AppTheme.ROSE -> "#D81B60"
AppTheme.WHITE -> "#F5F5F5"
}

View file

@ -8,6 +8,11 @@ import platform.UIKit.UIUserInterfaceIdiomPhone
private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
private const val nativeSelectedTabKey = "NuvioNativeSelectedTab"
private const val nativeTabAccentColorKey = "NuvioNativeTabAccentColor"
private const val nativeProfileNameKey = "NuvioNativeProfileName"
private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor"
private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL"
private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor"
private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange"
internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean {
@ -28,11 +33,37 @@ internal actual fun publishNativeSelectedTab(tabName: String) {
notifyNativeTabChromeChanged()
}
internal actual fun publishNativeTabAccentColor(hexColor: String) {
NSUserDefaults.standardUserDefaults.setObject(hexColor, forKey = nativeTabAccentColorKey)
notifyNativeTabChromeChanged()
}
internal actual fun publishNativeProfileTabIcon(
name: String?,
avatarColorHex: String?,
avatarImageUrl: String?,
avatarBackgroundColorHex: String?,
) {
publishString(nativeProfileNameKey, name)
publishString(nativeProfileAvatarColorKey, avatarColorHex)
publishString(nativeProfileAvatarUrlKey, avatarImageUrl)
publishString(nativeProfileAvatarBackgroundColorKey, avatarBackgroundColorHex)
notifyNativeTabChromeChanged()
}
private fun publishBool(key: String, value: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(value, forKey = key)
notifyNativeTabChromeChanged()
}
private fun publishString(key: String, value: String?) {
if (value.isNullOrBlank()) {
NSUserDefaults.standardUserDefaults.removeObjectForKey(key)
} else {
NSUserDefaults.standardUserDefaults.setObject(value, forKey = key)
}
}
private fun notifyNativeTabChromeChanged() {
NSNotificationCenter.defaultCenter.postNotificationName(nativeTabChromeDidChangeNotification, null)
}

View file

@ -2,6 +2,257 @@ import UIKit
import SwiftUI
import ComposeApp
private enum NuvioNativeTabIcon {
static let home = vectorIcon(
viewport: CGSize(width: 24, height: 24),
paths: [
"M10,20V14H14V20H19V12H22L12,3L2,12H5V20Z",
]
)
static let search = drawnIcon { context, rect in
drawInViewport(context: context, rect: rect, viewport: CGSize(width: 20, height: 20)) {
context.setStrokeColor(UIColor.black.cgColor)
context.setLineWidth(2)
context.setLineCap(.round)
context.strokeEllipse(in: CGRect(x: 3, y: 3, width: 12, height: 12))
context.move(to: CGPoint(x: 13.6, y: 13.6))
context.addLine(to: CGPoint(x: 17, y: 17))
context.strokePath()
}
}
static let library = vectorIcon(
viewport: CGSize(width: 24, height: 24),
paths: [
"M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z",
"M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z",
"M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z",
]
)
static let profileFallback = vectorIcon(
viewport: CGSize(width: 24, height: 24),
paths: [
"M12,12C14.21,12 16,10.21 16,8C16,5.79 14.21,4 12,4C9.79,4 8,5.79 8,8C8,10.21 9.79,12 12,12ZM12,14C9.33,14 4,15.34 4,18V19C4,19.55 4.45,20 5,20H19C19.55,20 20,19.55 20,19V18C20,15.34 14.67,14 12,14Z",
]
)
static func profileAvatar(
name: String?,
avatarColor: UIColor?,
backgroundColor: UIColor?,
avatarImage: UIImage?,
selected: Bool,
accent: UIColor
) -> UIImage {
guard name != nil || avatarColor != nil || avatarImage != nil else {
return profileFallback
}
let size = CGSize(width: 28, height: 28)
let baseColor = avatarColor ?? UIColor(red: 30.0 / 255.0, green: 136.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15)
let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5)
let initial = name?
.trimmingCharacters(in: .whitespacesAndNewlines)
.prefix(1)
.uppercased() ?? ""
return UIGraphicsImageRenderer(size: size).image { _ in
let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1)
fillColor.setFill()
UIBezierPath(ovalIn: rect).fill()
if let avatarImage {
UIBezierPath(ovalIn: rect).addClip()
drawAspectFill(image: avatarImage, in: rect)
} else if !initial.isEmpty {
let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: baseColor,
]
let textSize = initial.size(withAttributes: attributes)
initial.draw(
at: CGPoint(
x: rect.midX - textSize.width / 2,
y: rect.midY - textSize.height / 2
),
withAttributes: attributes
)
} else {
profileFallback
.withTintColor(baseColor, renderingMode: .alwaysOriginal)
.draw(in: rect.insetBy(dx: 5.5, dy: 5.5))
}
borderColor.setStroke()
let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75))
borderPath.lineWidth = 1.5
borderPath.stroke()
}.withRenderingMode(.alwaysOriginal)
}
private static func drawInViewport(
context: CGContext,
rect: CGRect,
viewport: CGSize,
draw: () -> Void
) {
let scale = min(rect.width / viewport.width, rect.height / viewport.height)
let x = rect.midX - viewport.width * scale / 2
let y = rect.midY - viewport.height * scale / 2
context.saveGState()
context.translateBy(x: x, y: y)
context.scaleBy(x: scale, y: scale)
draw()
context.restoreGState()
}
private static func vectorIcon(viewport: CGSize, paths: [String], size: CGSize = CGSize(width: 25, height: 25)) -> UIImage {
drawnIcon(size: size) { context, rect in
drawInViewport(context: context, rect: rect, viewport: viewport) {
context.setFillColor(UIColor.black.cgColor)
paths.compactMap { SVGPath(data: $0).cgPath }.forEach { path in
context.addPath(path)
context.fillPath(using: .evenOdd)
}
}
}
}
private static func drawnIcon(
size: CGSize = CGSize(width: 25, height: 25),
draw: @escaping (CGContext, CGRect) -> Void
) -> UIImage {
UIGraphicsImageRenderer(size: size).image { rendererContext in
draw(rendererContext.cgContext, CGRect(origin: .zero, size: size))
}.withRenderingMode(.alwaysTemplate)
}
private static func drawAspectFill(image: UIImage, in rect: CGRect) {
guard image.size.width > 0, image.size.height > 0 else { return }
let scale = max(rect.width / image.size.width, rect.height / image.size.height)
let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
let drawRect = CGRect(
x: rect.midX - drawSize.width / 2,
y: rect.midY - drawSize.height / 2,
width: drawSize.width,
height: drawSize.height
)
image.draw(in: drawRect)
}
private struct SVGPath {
private enum Token {
case command(Character)
case number(CGFloat)
}
let data: String
var cgPath: CGPath? {
let tokens = Self.tokens(from: data)
var index = 0
var command: Character?
var current = CGPoint.zero
var subpathStart = CGPoint.zero
let path = CGMutablePath()
func hasNumber() -> Bool {
guard index < tokens.count else { return false }
if case .number = tokens[index] { return true }
return false
}
func readNumber() -> CGFloat? {
guard index < tokens.count else { return nil }
guard case let .number(value) = tokens[index] else { return nil }
index += 1
return value
}
func readPoint(relative: Bool) -> CGPoint? {
guard let x = readNumber(), let y = readNumber() else { return nil }
let point = CGPoint(x: x, y: y)
return relative ? CGPoint(x: current.x + point.x, y: current.y + point.y) : point
}
while index < tokens.count {
if case let .command(value) = tokens[index] {
command = value
index += 1
}
guard let activeCommand = command else { return nil }
let relative = activeCommand.isLowercase
switch activeCommand.uppercased() {
case "M":
guard let point = readPoint(relative: relative) else { return nil }
path.move(to: point)
current = point
subpathStart = point
command = relative ? "l" : "L"
case "L":
while hasNumber() {
guard let point = readPoint(relative: relative) else { return nil }
path.addLine(to: point)
current = point
}
case "H":
while hasNumber() {
guard let x = readNumber() else { return nil }
let point = CGPoint(x: relative ? current.x + x : x, y: current.y)
path.addLine(to: point)
current = point
}
case "V":
while hasNumber() {
guard let y = readNumber() else { return nil }
let point = CGPoint(x: current.x, y: relative ? current.y + y : y)
path.addLine(to: point)
current = point
}
case "C":
while hasNumber() {
guard
let c1 = readPoint(relative: relative),
let c2 = readPoint(relative: relative),
let end = readPoint(relative: relative)
else { return nil }
path.addCurve(to: end, control1: c1, control2: c2)
current = end
}
case "Z":
path.closeSubpath()
current = subpathStart
default:
return nil
}
}
return path
}
private static func tokens(from data: String) -> [Token] {
let pattern = "[MmLlHhVvCcZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?"
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let range = NSRange(data.startIndex..<data.endIndex, in: data)
return regex.matches(in: data, range: range).compactMap { match in
guard let tokenRange = Range(match.range, in: data) else { return nil }
let token = String(data[tokenRange])
if token.count == 1, let character = token.first, character.isLetter {
return .command(character)
}
guard let value = Double(token) else { return nil }
return .number(CGFloat(value))
}
}
}
}
final class RootComposeViewController: UIViewController, UITabBarDelegate {
private enum NativeTab: String, CaseIterable {
case home = "Home"
@ -27,12 +278,12 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
}
}
var systemImageName: String {
var iconImage: UIImage {
switch self {
case .home: return "house"
case .search: return "magnifyingglass"
case .library: return "books.vertical"
case .settings: return "person.crop.circle"
case .home: return NuvioNativeTabIcon.home
case .search: return NuvioNativeTabIcon.search
case .library: return NuvioNativeTabIcon.library
case .settings: return NuvioNativeTabIcon.profileFallback
}
}
@ -45,6 +296,11 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
private static let liquidGlassEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled"
private static let nativeTabBarVisibleKey = "NuvioNativeTabBarVisible"
private static let nativeSelectedTabKey = "NuvioNativeSelectedTab"
private static let nativeTabAccentColorKey = "NuvioNativeTabAccentColor"
private static let nativeProfileNameKey = "NuvioNativeProfileName"
private static let nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor"
private static let nativeProfileAvatarURLKey = "NuvioNativeProfileAvatarURL"
private static let nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor"
private static let nativeTabChromeDidChangeNotification = Notification.Name("NuvioNativeTabChromeDidChange")
private let contentController: UIViewController
@ -53,6 +309,9 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
private var tabBarHeightConstraint: NSLayoutConstraint?
private var userDefaultsObserver: NSObjectProtocol?
private var tabChromeObserver: NSObjectProtocol?
private var profileAvatarImageURL: String?
private var profileAvatarImageTask: URLSessionDataTask?
private var profileAvatarImage: UIImage?
init(contentController: UIViewController) {
self.contentController = contentController
@ -96,6 +355,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
if let tabChromeObserver {
NotificationCenter.default.removeObserver(tabChromeObserver)
}
profileAvatarImageTask?.cancel()
}
override func viewSafeAreaInsetsDidChange() {
@ -180,13 +440,16 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
tabBar.delegate = self
tabBar.translatesAutoresizingMaskIntoConstraints = false
tabBar.items = NativeTab.allCases.map { tab in
UITabBarItem(
let item = UITabBarItem(
title: tab.title,
image: UIImage(systemName: tab.systemImageName),
tag: tab.tag
image: tab.iconImage,
selectedImage: tab.iconImage
)
item.tag = tab.tag
return item
}
tabBar.selectedItem = tabBar.items?.first
applyNativeTabBarAppearance()
tabBar.alpha = 0
tabBar.isHidden = true
@ -229,6 +492,7 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
private func syncNativeTabChrome(animated: Bool) {
updateTabBarHeight()
applyNativeTabBarAppearance()
syncSelectedNativeTab()
let visible = shouldShowNativeTabBar
@ -265,6 +529,105 @@ final class RootComposeViewController: UIViewController, UITabBarDelegate {
let selectedTab = NativeTab(rawValue: rawValue) ?? .home
tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag })
}
private func applyNativeTabBarAppearance() {
let accent = UIColor(hexString: UserDefaults.standard.string(forKey: Self.nativeTabAccentColorKey)) ??
UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1)
let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1)
refreshProfileAvatarImageIfNeeded()
updateNativeTabImages(accent: accent)
tabBar.tintColor = accent
tabBar.unselectedItemTintColor = unselected
let appearance = tabBar.standardAppearance.copy() as! UITabBarAppearance
appearance.stackedLayoutAppearance.normal.iconColor = unselected
appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
appearance.stackedLayoutAppearance.selected.iconColor = accent
appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
appearance.inlineLayoutAppearance.normal.iconColor = unselected
appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
appearance.inlineLayoutAppearance.selected.iconColor = accent
appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
appearance.compactInlineLayoutAppearance.normal.iconColor = unselected
appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected]
appearance.compactInlineLayoutAppearance.selected.iconColor = accent
appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent]
tabBar.standardAppearance = appearance
tabBar.scrollEdgeAppearance = appearance
}
private func updateNativeTabImages(accent: UIColor) {
tabBar.items?.forEach { item in
guard let tab = NativeTab(tag: item.tag) else { return }
item.image = nativeTabImage(for: tab, selected: false, accent: accent)
item.selectedImage = nativeTabImage(for: tab, selected: true, accent: accent)
}
}
private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage {
guard tab == .settings else {
return tab.iconImage
}
let defaults = UserDefaults.standard
return NuvioNativeTabIcon.profileAvatar(
name: defaults.string(forKey: Self.nativeProfileNameKey),
avatarColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarColorKey)),
backgroundColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarBackgroundColorKey)),
avatarImage: profileAvatarImage,
selected: selected,
accent: accent
)
}
private func refreshProfileAvatarImageIfNeeded() {
let urlString = UserDefaults.standard.string(forKey: Self.nativeProfileAvatarURLKey)
guard urlString != profileAvatarImageURL else { return }
profileAvatarImageTask?.cancel()
profileAvatarImageTask = nil
profileAvatarImageURL = urlString
profileAvatarImage = nil
guard let urlString, let url = URL(string: urlString) else { return }
profileAvatarImageTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard
let self,
let data,
let image = UIImage(data: data)
else { return }
DispatchQueue.main.async {
guard self.profileAvatarImageURL == urlString else { return }
self.profileAvatarImage = image
self.applyNativeTabBarAppearance()
}
}
profileAvatarImageTask?.resume()
}
}
private extension UIColor {
convenience init?(hexString: String?) {
guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
if value.hasPrefix("#") {
value.removeFirst()
}
guard value.count == 6, let rgb = UInt64(value, radix: 16) else {
return nil
}
self.init(
red: CGFloat((rgb >> 16) & 0xFF) / 255,
green: CGFloat((rgb >> 8) & 0xFF) / 255,
blue: CGFloat(rgb & 0xFF) / 255,
alpha: 1
)
}
}
struct ComposeView: UIViewControllerRepresentable {