When we started building Skyscraper, our Bluesky client for iOS, one of the first decisions was choosing between SwiftUI and UIKit. Social media apps present unique challenges: infinite scrolling feeds, dynamic content with varying heights, embedded media, real-time updates, and the expectation of buttery-smooth 60fps performance. Here's what we learned.
The Social Media App Challenge
Before comparing frameworks, let's understand what makes social media apps technically demanding:
- Infinite scrolling - Users expect to scroll through hundreds or thousands of posts
- Variable cell heights - Posts with text, images, videos, embeds, and quotes all have different sizes
- Rich media - Images need lazy loading, caching, and smooth transitions
- Real-time updates - New posts, likes, and reposts should appear without jarring refreshes
- Complex interactions - Likes, reposts, replies, and swipe gestures need to feel instant
- Memory management - Can't keep thousands of posts in memory
SwiftUI: The Modern Choice
SwiftUI is Apple's declarative UI framework, introduced in 2019 and continuously improved since. Here's how it stacks up for social media apps:
SwiftUI Advantages
1. Declarative Syntax = Faster Development
SwiftUI's declarative approach means less code and faster iteration:
// SwiftUI: A post cell in ~30 lines
struct PostView: View {
let post: Post
@State private var isLiked = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Author header
HStack {
AsyncImage(url: post.author.avatarURL)
.frame(width: 44, height: 44)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(post.author.displayName)
.font(.headline)
Text("@\(post.author.handle)")
.foregroundColor(.secondary)
}
}
// Post content
Text(post.text)
// Action buttons
HStack {
Button(action: { isLiked.toggle() }) {
Image(systemName: isLiked ? "heart.fill" : "heart")
}
}
}
.padding()
}
}
2. Built-in State Management
SwiftUI's @State, @Binding, @ObservableObject, and the new @Observable macro make reactive UIs straightforward:
@Observable
class TimelineViewModel {
var posts: [Post] = []
var isLoading = false
func loadMore() async {
isLoading = true
let newPosts = await api.getTimeline()
posts.append(contentsOf: newPosts)
isLoading = false
}
}
3. Automatic Animations
Adding animations is trivial - great for like buttons, expanding content, and transitions:
Button(action: {
withAnimation(.spring(response: 0.3)) {
isLiked.toggle()
}
}) {
Image(systemName: isLiked ? "heart.fill" : "heart")
.foregroundColor(isLiked ? .red : .gray)
.scaleEffect(isLiked ? 1.2 : 1.0)
}
4. Preview Canvas
SwiftUI Previews dramatically speed up UI development - see changes instantly without rebuilding:
#Preview {
PostView(post: .sample)
}
#Preview("Dark Mode") {
PostView(post: .sample)
.preferredColorScheme(.dark)
}
5. Cross-Platform Potential
SwiftUI code can run on iOS, iPadOS, macOS, watchOS, and tvOS with minimal changes. One codebase for your Bluesky client across all Apple platforms.
SwiftUI Disadvantages
1. List Performance Concerns
SwiftUI's List and LazyVStack have improved significantly, but can still struggle with complex cells:
// This can be slow with complex cells
LazyVStack {
ForEach(posts) { post in
PostView(post: post) // Complex view with images, embeds, etc.
}
}
// Solutions:
// 1. Use List with explicit IDs
// 2. Simplify cell views
// 3. Use UIKit via UIViewRepresentable for the scroll view
2. Less Control Over Scroll Behavior
Fine-tuning scroll behavior (prefetching, maintaining scroll position during updates) is harder:
// UIKit gives you UIScrollViewDelegate with fine-grained control
// SwiftUI's ScrollView callbacks are more limited
ScrollView {
LazyVStack {
// Limited control over prefetching distance
// Harder to maintain position when inserting items
}
}
3. Memory and View Identity Issues
SwiftUI can recreate views unexpectedly, causing image reloads and state loss:
// Problem: Image might reload if view identity changes
AsyncImage(url: post.imageURL)
// Solution: Stable identity and caching
AsyncImage(url: post.imageURL)
.id(post.id) // Stable identity
// Plus: Use a proper image caching library like Kingfisher or Nuke
4. Debugging Difficulty
When SwiftUI views don't update or perform poorly, diagnosing the issue can be challenging. The declarative nature that makes development fast can make debugging slow.
UIKit: The Battle-Tested Veteran
UIKit has powered iOS apps since 2008. It's imperative, verbose, but incredibly powerful.
UIKit Advantages
1. Proven Scrolling Performance
UITableView and UICollectionView are engineered for infinite scrolling:
class TimelineViewController: UIViewController {
private let tableView = UITableView()
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(PostCell.self, forCellReuseIdentifier: "post")
tableView.prefetchDataSource = self // Prefetch images before visible
tableView.estimatedRowHeight = 200
tableView.rowHeight = UITableView.automaticDimension
}
}
// Prefetching for smooth scrolling
extension TimelineViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let post = posts[indexPath.row]
ImageCache.shared.prefetch(post.imageURLs)
}
}
}
2. Fine-Grained Scroll Control
UIScrollViewDelegate gives you complete control:
extension TimelineViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Load more when near bottom
let offsetY = scrollView.contentOffset.y
let contentHeight = scrollView.contentSize.height
if offsetY > contentHeight - scrollView.frame.height * 2 {
loadMorePosts()
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// Pause video playback while scrolling
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// Resume video playback for visible cells
}
}
3. Mature Cell Reuse System
UITableView's cell reuse is battle-tested and efficient:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "post", for: indexPath) as! PostCell
let post = posts[indexPath.row]
cell.configure(with: post)
return cell
}
class PostCell: UITableViewCell {
override func prepareForReuse() {
super.prepareForReuse()
// Cancel image loads, reset state
imageView?.image = nil
contentLabel.text = nil
}
}
4. Diffable Data Sources
Modern UIKit has excellent tools for animating data changes:
var dataSource: UITableViewDiffableDataSource!
func updatePosts(_ newPosts: [Post]) {
var snapshot = NSDiffableDataSourceSnapshot()
snapshot.appendSections([.main])
snapshot.appendItems(newPosts)
dataSource.apply(snapshot, animatingDifferences: true)
}
UIKit Disadvantages
1. Verbose Boilerplate
UIKit requires significantly more code for the same UI:
// UIKit: Setting up a simple label
let label = UILabel()
label.text = "Hello"
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = .label
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16)
])
// SwiftUI equivalent:
Text("Hello")
.font(.headline)
.padding()
2. Manual State Management
You're responsible for keeping UI in sync with data:
// Must manually update UI when data changes
var isLiked: Bool = false {
didSet {
updateLikeButton()
}
}
func updateLikeButton() {
let imageName = isLiked ? "heart.fill" : "heart"
likeButton.setImage(UIImage(systemName: imageName), for: .normal)
likeButton.tintColor = isLiked ? .red : .gray
}
3. No Live Previews
You need to build and run to see UI changes (though Xcode Previews can work with UIKit via PreviewProvider, it's not as seamless).
4. Legacy Feel
UIKit's imperative patterns can feel dated compared to modern declarative frameworks.
The Hybrid Approach: Best of Both Worlds
Many successful apps use both frameworks. Here's how:
SwiftUI Shell, UIKit Lists
// Main app structure in SwiftUI
struct ContentView: View {
var body: some View {
TabView {
TimelineView()
.tabItem { Label("Home", systemImage: "house") }
DiscoverView()
.tabItem { Label("Discover", systemImage: "magnifyingglass") }
}
}
}
// High-performance timeline using UIKit
struct TimelineView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> TimelineViewController {
TimelineViewController()
}
func updateUIViewController(_ uiViewController: TimelineViewController, context: Context) {
// Update if needed
}
}
UIKit Cells in SwiftUI Lists
// Use UIKit cells for complex content
struct PostCell: UIViewRepresentable {
let post: Post
func makeUIView(context: Context) -> PostUIView {
PostUIView()
}
func updateUIView(_ uiView: PostUIView, context: Context) {
uiView.configure(with: post)
}
}
Performance Tips for Either Framework
Regardless of your choice, these tips will help:
1. Image Caching is Critical
// Use a proper image caching library
// Kingfisher, Nuke, or SDWebImage
// Don't do this:
AsyncImage(url: url) // No caching!
// Do this:
KFImage(url)
.resizable()
.placeholder { ProgressView() }
.fade(duration: 0.25)
2. Lazy Loading Everything
// Only load what's visible
LazyVStack { ... } // SwiftUI
tableView.prefetchDataSource = self // UIKit
3. Measure, Don't Guess
// Use Instruments to profile:
// - Time Profiler for CPU usage
// - Allocations for memory
// - Core Animation for rendering
// Add signposts for custom measurements
import os.signpost
let log = OSLog(subsystem: "com.app", category: "performance")
os_signpost(.begin, log: log, name: "Load Timeline")
// ... load timeline
os_signpost(.end, log: log, name: "Load Timeline")
4. Avoid Expensive Operations on Main Thread
// Bad: Parsing on main thread
let posts = try JSONDecoder().decode([Post].self, from: data)
// Good: Parse on background thread
Task.detached {
let posts = try JSONDecoder().decode([Post].self, from: data)
await MainActor.run {
self.posts = posts
}
}
Our Recommendation for Bluesky Clients
After building Skyscraper, here's our take:
- New projects in 2025: Start with SwiftUI. The developer experience is vastly better, and performance has improved significantly. You can always drop to UIKit for specific components.
- Performance-critical feeds: Consider UIViewRepresentable with UITableView/UICollectionView for the main timeline if you hit SwiftUI limits.
- Everything else: SwiftUI for settings, profiles, compose screens, and navigation. The productivity gains are worth it.
- Target iOS 17+: This unlocks the best SwiftUI performance and features like
@Observable.
Building Your Bluesky Client
Whether you choose SwiftUI, UIKit, or a hybrid approach, the key is shipping something users love. A fast, responsive client that helps users grow their Bluesky presence and engage with the community will succeed regardless of the underlying framework.
Want to see what's trending while you build? Check out our tools:
- Trending Hashtags - Discover what's popular on Bluesky
- Bluesky Tools - Monitor discussions about iOS development and Bluesky
- Try Skyscraper - See our SwiftUI-based Bluesky client in action
Happy coding, and may your scroll views always be smooth!