<!-- mobian-agent-page publisher="dailydev" canonical="https://daily.dev/posts/integrating-google-gemini-ai-with-swift-and-swiftui-fhwff24ml" -->

---
title: Integrating Google Gemini AI with Swift and SwiftUI
description: Learn how to integrate Google Gemini AI into a SwiftUI app using a few lines of code. Explore the capabilities of the `gemini-pro` model for text-based input...
canonical: https://daily.dev/posts/integrating-google-gemini-ai-with-swift-and-swiftui-fhwff24ml
twitter:card: summary_large_image
twitter:site: @dailydotdev
og:type: website
og:site_name: daily.dev
og:title: Integrating Google Gemini AI with Swift and SwiftUI | daily.dev
og:description: Learn how to integrate Google Gemini AI into a SwiftUI app using a few lines of code. Explore the capabilities of the `gemini-pro` model for text-based input...
og:url: https://daily.dev/posts/integrating-google-gemini-ai-with-swift-and-swiftui-fhwff24ml
og:image: https://api.daily.dev/og/posts/fhwFF24mL.png
og:image:alt: Integrating Google Gemini AI with Swift and SwiftUI
og:image:width: 1200
og:image:height: 630
og:locale: en
---

> ## Documentation Index
> Fetch the complete documentation index at: https://daily.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Integrating Google Gemini AI with Swift and SwiftUI

**[Appcoda](https://daily.dev/sources/appcoda)** · 6 min read · 1 upvotes · 1 comments

## Summary

Learn how to integrate Google Gemini AI into a SwiftUI app using a few lines of code. Explore the capabilities of the `gemini-pro` model for text-based input and the `gemini-pro-vision` model for multimodal input with text and images.

## Full article

daily.dev links to this article rather than hosting it. Read it at the original source: <https://www.appcoda.com/swiftui-google-gemini-ai/>

## Community discussion

Top comments from developers on daily.dev.

**@kapilkumar12** · 0 upvotes

> //
>
> //  VoicemailView.swift
>
> //  MobileOffice
>
> //
>
> //
>
>
> **import** SwiftUI
>
> **import** AVFoundation
>
>
> **struct** VoicemailView: View {
>
> @ObservedObject **var** viewAdapter: VoicemailViewAdapter
>
>
> **private** **let** animationDuration = 0.25
>
>
> // **MARK: - State Variables**
>
>
> /// Consolidated selection/editing/delete-confirmation/audio-menu state (see VoicemailViewState.swift)
>
> @StateObject **private** **var** viewState = VoicemailViewState()
>
>
> @State **private** **var** navigationPath = NavigationPath()
>
>
> // Info popup components
>
> @State **private** **var** infoPopupWireframe:...

## Similar posts on daily.dev

- [Don’t just attend KubeCon \+ CloudNativeCon, Merge Forward your experience\!](https://daily.dev/posts/don-t-just-attend-kubecon-cloudnativecon-merge-forward-your-experience--l0rpp73x8) · CNCF · 1 upvotes · 0 comments
- [Announcing H2 2026 KCDs](https://daily.dev/posts/announcing-h2-2026-kcds-m96goajm1) · CNCF · 1 upvotes · 0 comments
- [Two months of Open Community Groups](https://daily.dev/posts/two-months-of-open-community-groups-asf52zhbs) · CNCF · 0 upvotes · 0 comments

---

Tags: [#ai](https://daily.dev/tags/ai), [#swift](https://daily.dev/tags/swift), [#swiftui](https://daily.dev/tags/swiftui), [#google-gemini](https://daily.dev/tags/google-gemini)

[View this post on daily.dev](https://daily.dev/posts/integrating-google-gemini-ai-with-swift-and-swiftui-fhwff24ml)

```json
{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://daily.dev/#organization","name":"daily.dev","url":"https://daily.dev","logo":{"@type":"ImageObject","url":"https://daily.dev/apple-touch-icon.png","width":180,"height":180},"sameAs":["https://twitter.com/dailydotdev","https://github.com/dailydotdev","https://www.linkedin.com/company/daily-dev-ltd"]},{"@type":"WebSite","@id":"https://daily.dev/#website","url":"https://daily.dev","name":"daily.dev","publisher":{"@id":"https://daily.dev/#organization"},"potentialAction":{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https://daily.dev/search?q={search_term_string}"},"query-input":"required name=search_term_string"}}]}
{"@context":"https://schema.org","@type":"TechArticle","headline":"Integrating Google Gemini AI with Swift and SwiftUI","url":"https://daily.dev/posts/integrating-google-gemini-ai-with-swift-and-swiftui-fhwff24ml","mainEntityOfPage":{"@type":"WebPage","@id":"https://daily.dev/posts/integrating-google-gemini-ai-with-swift-and-swiftui-fhwff24ml"},"datePublished":"2024-04-25T12:14:59.012Z","dateModified":"2024-05-09T09:23:16.455Z","description":"Learn how to integrate Google Gemini AI into a SwiftUI app using a few lines of code. Explore the capabilities of the `gemini-pro` model for text-based input...","image":"https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/f79935cb09bd1342f23f0050bf1b25f7?_a=AQAEufR","thumbnailUrl":"https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/f79935cb09bd1342f23f0050bf1b25f7?_a=AQAEufR","isAccessibleForFree":true,"articleSection":"Appcoda","inLanguage":"en","publisher":{"@type":"Organization","name":"daily.dev","url":"https://daily.dev","logo":{"@type":"ImageObject","url":"https://daily.dev/apple-touch-icon.png","width":180,"height":180}},"author":{"@type":"Organization","name":"Appcoda","logo":"https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/3e3b5f1859c54a75967f2b0bb5e0f23c","url":"https://daily.dev/sources/appcoda"},"commentCount":1,"discussionUrl":"https://daily.dev/posts/integrating-google-gemini-ai-with-swift-and-swiftui-fhwff24ml","interactionStatistic":[{"@type":"InteractionCounter","interactionType":{"@type":"LikeAction"},"userInteractionCount":1},{"@type":"InteractionCounter","interactionType":{"@type":"CommentAction"},"userInteractionCount":1}],"keywords":"ai,swift,swiftui,google-gemini","timeRequired":"PT6M"}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://daily.dev"},{"@type":"ListItem","position":2,"name":"Appcoda","item":"https://daily.dev/sources/appcoda"},{"@type":"ListItem","position":3,"name":"Integrating Google Gemini AI with Swift and SwiftUI"}]}
{"@context":"https://schema.org","@type":"WebPage","@id":"https://daily.dev/posts/integrating-google-gemini-ai-with-swift-and-swiftui-fhwff24ml","comment":[{"@type":"Comment","text":"//\n//  VoicemailView.swift\n//  MobileOffice\n//\n//\nimport SwiftUI\nimport AVFoundation\nstruct VoicemailView: View {\n@ObservedObject var viewAdapter: VoicemailViewAdapter\nprivate let animationDuration = 0.25\n// MARK: - State Variables\n/// Consolidated selection/editing/delete-confirmation/audio-menu state (see VoicemailViewState.swift)\n@StateObject private var viewState = VoicemailViewState()\n@State private var navigationPath = NavigationPath()\n// Info popup components\n@State private var infoPopupWireframe: InfoPopupWireframe?\n@State private var infoPopupPresenter: InfoPopupPresenter?\n@State private var infoPopupViewAdapter: InfoPopupViewAdapter?\n// Bottom action bar\n@State private var bottomBarPresenter: BottomActionBarPresenter?\n@State private var bottomBarViewAdapter: BottomActionBarViewAdapter?\n@State private var bottomBarHostingController: UIHostingController&lt;BottomActionBarView&gt;?\n// Audio menu hosting\n@State private var audioMenuHostingController: UIHostingController&lt;AudioOutputMenu&gt;?\n@State private var backdropHostingController: UIHostingController&lt;BackdropView&gt;?\n// Mailbox filter (“All Inbox”) menu hosting\n@State private var mailboxMenuHostingController: UIHostingController&lt;MailboxFilterMenu&gt;?\n@State private var mailboxBackdropHostingController: UIHostingController&lt;BackdropView&gt;?\n@State private var progressHostingController: UIHostingController&lt;ProgressHUDView&gt;?\nvar body: some View {\nNavigationStack(path: $navigationPath) {\nZStack {\nGeometryReader { outerGeometry in\nScrollViewReader { proxy in\nList {\nvoicemailListHeader\nSection {\nForEach(viewAdapter.voicemails) { voicemail in\nvoicemailRow(\nvoicemail: voicemail,\nouterGeometry: outerGeometry,\nproxy: proxy\n)\n}\n} header: {\nstickyAllInboxHeader\n}\n}\n.listStyle(.plain)\n.coordinateSpace(name: “VoicemailList”)\n.scrollDismissesKeyboard(.interactively)\n.refreshable {\nawait refreshVoicemails()\n}\n.animation(.easeInOut(duration: animationDuration), value: viewState.expandedVoicemailId)\n}\n}\n.standardTopBar(title: “Voicemail”, profileAction: {\n}, titleVisible: $viewState.hasScrolledPastHeader)\n.navigationDestination(for: VoicemailObject.self) { voicemail in\nlet viewAdapter = ContactDetailDependencyContainer.buildViewAdapter(voicemail: voicemail)\nContactDetailView(viewAdapter: viewAdapter)\n}\n.toolbar {\nif #available(iOS 26.0, *) {\nToolbarItem(placement: .navigationBarTrailing) {\neditButton\n}\n.sharedBackgroundVisibility(.hidden)\n} else {\nToolbarItem(placement: .navigationBarTrailing) {\neditButton\n}\n}\n}\n.onAppear {\nTask {\nawait viewAdapter.viewDidLoad()\n}\nif viewState.selectedMailboxIds.isEmpty {\nviewState.selectedMailboxIds = Set(mailboxOptions.map(.id))\n}\n// Setup callbacks to handle selection updates\nviewAdapter.onVoicemailMarkedAsRead = { id in\nviewState.selectedVoicemails.remove(id)\nupdateBottomBarState()\n}\nviewAdapter.onVoicemailDeleted = { id in\nviewState.selectedVoicemails.remove(id)\nupdateBottomBarState()\n}\n}\n}\n}\n.alert(“”, isPresented: $viewState.showDeleteConfirmation, presenting: viewState.voicemailToDelete) { voicemail in\nButton(“Cancel”, role: .cancel) {}\nButton(“Delete”, role: .destructive) {\nTask {\nawait viewAdapter.deleteVoicemails(ids: [voicemail.id])\n}\n}\n} message: { voicemail in\nSwiftUI.Text(“Are you sure you want to delete?”)\n}\n.overlay {\nif viewAdapter.shouldShowError {\nProgressHUDView(message: viewAdapter.errorMessage)\n}\n}\n.onChange(of: viewAdapter.shouldShowError) { show in\nif show {\nDispatchQueue.main.asyncAfter(deadline: .now() + 2) {\nviewAdapter.shouldShowError = false\n}\n}\n}\n.onChange(of: viewState.isEditing) { newValue in\nif newValue {\nshowBottomActionBar()\ncollapseExpandedRow()\n} else {\nhideBottomActionBar()\n}\n}\n.onChange(of: viewState.selectedVoicemails) { _ in\nupdateBottomBarState()\n}\n.onChange(of: viewState.showAudioOutputMenu) { newValue in\nif newValue {\nDispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {\nself.showAudioOutputMenuAtWindow()\n}\n} else {\nhideAudioOutputMenuFromWindow()\n}\n}\n.onChange(of: viewState.showMailboxFilterMenu) { newValue in\nif newValue {\nDispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {\nself.showMailboxFilterMenuAtWindow()\n}\n} else {\nhideMailboxFilterMenuFromWindow()\n}\n}\n.onChange(of: viewAdapter.isLoadingData) { isLoading in\nTask { @MainActor in\nif isLoading {\nshowWindowLevelProgress(message: viewAdapter.progressMessage)\n} else {\nhideWindowLevelProgress()\n}\n}\n}\n.onChange(of: viewAdapter.progressMessage) { message in\nTask { @MainActor in\nif viewAdapter.isLoadingData &amp;&amp; !message.isEmpty {\nupdateWindowLevelProgress(message: message)\n}\n}\n}\n}\n// MARK: - Toolbar Content\nprivate var editButton: some View {\nButton {\nwithAnimation {\nviewState.isEditing.toggle()\n}\nif !viewState.isEditing {\nviewState.selectedVoicemails.removeAll()\ncollapseExpandedRow()\n}\n} label: {\nif viewState.isEditing {\nSwiftUI.Text(“Done”)\n.foregroundColor(.green)\n.font(.system(size: 17, weight: .semibold))\n} else {\nImage(“edit_button”)\n.resizable()\n.scaledToFit()\n.frame(width: 44, height: 44)\n.foregroundColor(.white)\n}\n}\n.buttonStyle(.plain)\n}\n// MARK: - List Header (“Voicemail” title + “All Inbox” filter)\nprivate var voicemailListHeader: some View {\nVStack(alignment: .leading, spacing: 6) {\nSwiftUI.Text(“Voicemail”)\n.font(.system(size: 34, weight: .bold))\n.foregroundColor(Color(UIColor.label))\n}\n.padding(.horizontal, 16)\n.padding(.top, 8)\n.padding(.bottom, 12)\n.listRowInsets(EdgeInsets())\n.listRowSeparator(.hidden)\n.background(\nGeometryReader { geo in\nColor.clear\n.onChange(of: geo.frame(in: .named(“VoicemailList”)).minY) { minY in\nwithAnimation(.easeInOut(duration: 0.2)) {\nviewState.hasScrolledPastHeader = minY &lt; -10\n}\n}\n}\n)\n}\nprivate var stickyAllInboxHeader: some View {\nHStack {\nSpacer()\nallInboxButton\n}\n.padding(.horizontal, 16)\n.frame(height: 44)\n.background(Color(UIColor.systemGroupedBackground))\n.listRowInsets(EdgeInsets())\n}\nprivate var allInboxButton: some View {\nButton {\nwithAnimation(.easeInOut(duration: 0.15)) {\nviewState.showMailboxFilterMenu.toggle()\n}\n} label: {\nHStack(spacing: 4) {\nSwiftUI.Text(“All Inbox”)\n.font(.system(size: 15))\nImage(systemName: “chevron.down”)\n.font(.system(size: 12, weight: .semibold))\n}\n.foregroundColor(Color(UIColor.secondaryLabel))\n}\n.buttonStyle(.plain)\n.background(\nGeometryReader { geo in\nColor.clear\n.onChange(of: geo.frame(in: .named(“VoicemailList”))) { newFrame in\nviewState.mailboxButtonFrame = newFrame\n}\n}\n)\n}\n// MARK: - Mailbox Filter (“All Inbox”)\n/// Real mailboxes (extensions) available to filter by; “All Inbox” is added separately in the menu.\nprivate var mailboxOptions: [MailboxFilterOption] {\nlet extensions = (ActiveUserSettings.shared()?.getCurrectExtensions() as? [[String: Any]]) ?? []\nreturn extensions.compactMap { extensionInfo in\nlet extensionId: String\nif let number = extensionInfo[“extension_id”] as? NSNumber {\nextensionId = number.stringValue\n} else if let stringId = extensionInfo[“extension_id”] as? String {\nextensionId = stringId\n} else {\nreturn nil\n}\nlet name = (extensionInfo[“extension_name”] as? String) ?? “”\nreturn MailboxFilterOption(id: extensionId, name: “(extensionId): (name)”)\n}\n}\nprivate func toggleMailboxSelection(_ optionId: String) {\nif optionId == “all” {\nlet allIds = Set(mailboxOptions.map(.id))\nlet isAllSelected = !allIds.isEmpty &amp;&amp; allIds.isSubset(of: viewState.selectedMailboxIds)\nviewState.selectedMailboxIds = isAllSelected ? [] : allIds\n} else if viewState.selectedMailboxIds.contains(optionId) {\nviewState.selectedMailboxIds.remove(optionId)\n} else {\nviewState.selectedMailboxIds.insert(optionId)\n}\n}\n// MARK: - Row Content Builders\nprivate func expandedRowContent(voicemail: VoicemailObject, rowGeometry: GeometryProxy, proxy: ScrollViewProxy) -&gt; some View {\n// Show InfoPopupPlayerView when expanded\nVStack(spacing: 0) {\n// InfoPopupPlayerView content\nif let viewAdapter = infoPopupViewAdapter {\nZStack {\n// White background for the expanded content\nColor.white\nInfoPopupPlayerView(\nviewAdapter: viewAdapter,\nshowAudioOutputMenu: $viewState.showAudioOutputMenu,\nspeakerButtonFrame: $viewState.speakerButtonFrame,\nselectedAudioOutput: $viewState.selectedAudioOutput,\ncoordinateSpace: “VoicemailList”\n)\n}\n}\n}\n.buttonStyle(PlainButtonStyle())\n.simultaneousGesture(\nTapGesture()\n.onEnded { _ in\n// Absorb taps at this level to prevent row collapse\n}\n)\n.background(\nGeometryReader { geo in\nColor.clear\n.onChange(of: geo.frame(in: .global)) { newFrame in\n// Update mask frames when row position changes during animation\nif viewState.expandedVoicemailId == voicemail.id {\n// Use a slight delay to ensure frame is stable after animation\nDispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {\nself.updateMaskFrames(rowGeometry: geo, scrollGeometry: rowGeometry)\n}\n}\n}\n.onAppear {\n// Show masks immediately when InfoPopupPlayerView appears\nself.updateMaskFrames(rowGeometry: geo, scrollGeometry: rowGeometry)\n// Calculate mask frames after expansion animation completes\nDispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {\nlet frame = geo.frame(in: .global)\nlet scrollFrame = rowGeometry.frame(in: .global)\n// Check visibility and scroll if needed\nself.checkVisibilityAndScroll(\nvoicemailId: voicemail.id,\nrowFrame: frame,\nscrollFrame: scrollFrame,\nproxy: proxy\n)\n// Update masks after scroll completes\nDispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {\nself.updateMaskFrames(rowGeometry: geo, scrollGeometry: rowGeometry)\n}\n}\n}\n}\n)\n}\nprivate func collapsedRowContent(voicemail: VoicemailObject, isSelected: Binding&lt;Bool&gt;, isOtherExpanded: Bool, scrollProxy: ScrollViewProxy) -&gt; some View {\n// Show normal row when collapsed\nVoicemailRow(\nvoicemail: voicemail,\nisEditing: viewState.isEditing,\nisSelected: isSelected,\nonInfoTapped: {\nif !viewState.isEditing {\nnavigationPath.append(voicemail)\n}\n},\nonCallTapped: {\nhandleCall(voicemail: voicemail)\n},\nonBlockTapped: {\nhandleBlock(voicemail: voicemail)\n},\nonMessageTapped: {\nhandleMessage(voicemail: voicemail)\n},\nonDeleteTapped: {\nviewState.voicemailToDelete = voicemail\nviewState.showDeleteConfirmation = true\n}\n)\n.disabled(isOtherExpanded)\n.contentShape(Rectangle())\n.onTapGesture {\nhandleRowTap(voicemail: voicemail, isSelected: isSelected, scrollProxy: scrollProxy)\n}\n}\n// Extracted per-row builder to help the compiler type-check large view expressions\n@ViewBuilder\nprivate func voicemailRow(voicemail: VoicemailObject, outerGeometry: GeometryProxy, proxy: ScrollViewProxy) -&gt; some View {\nlet isSelected: Binding&lt;Bool&gt; = Binding(\nget: { viewState.selectedVoicemails.contains(voicemail.id) },\nset: { newValue in\nif newValue {\nviewState.selectedVoicemails.insert(voicemail.id)\n} else {\nviewState.selectedVoicemails.remove(voicemail.id)\n}\n}\n)\nlet isExpanded = viewState.expandedVoicemailId == voicemail.id\nlet isOtherExpanded = viewState.expandedVoicemailId != nil &amp;&amp; !isExpanded\nif isExpanded {\nexpandedRowContent(voicemail: voicemail, rowGeometry: outerGeometry, proxy: proxy)\n.id(voicemail.id)\n} else {\ncollapsedRowContent(voicemail: voicemail, isSelected: isSelected, isOtherExpanded: isOtherExpanded, scrollProxy: proxy)\n.id(voicemail.id)\n}\n}\n// MARK: - Refresh Handler\nprivate func refreshVoicemails() async {\nawait viewAdapter.refreshVoicemails()\n}\n// MARK: - Swipe Action Handlers\nprivate func handleCall(voicemail: VoicemailObject) {\nguard let phoneNumber = voicemail.from?.number else { return }\n// Use the router to navigate to call screen\nviewAdapter.handleCall(phoneNumber: phoneNumber)\n}\nprivate func handleBlock(voicemail: VoicemailObject) {\nguard let phoneNumber = voicemail.from?.number else { return }\nlet isCurrentlyBlocked = (voicemail.from?.blackListID ?? 0) &gt; 0\nTask {\nif isCurrentlyBlocked {\nguard let blackListId = voicemail.from?.blackListID else { return }\nlet success = await viewAdapter.unblockContact(blackListId: blackListId)\nif success {\n// Update UI to reflect unblocked state\nif let index = viewAdapter.voicemails.firstIndex(where: { $0.id == voicemail.id }) {\nvar updatedVoicemail = viewAdapter.voicemails[index]\nupdatedVoicemail.from?.blackListID = 0\nviewAdapter.updateVoicemail(updatedVoicemail)\n}\n}\n} else {\nlet blackListId = await viewAdapter.blockContact(\nphoneNumber: phoneNumber,\ncurrentBlackListId: voicemail.from?.blackListID\n)\nif let blackListId = blackListId {\n// Update UI to reflect blocked state\nif let index = viewAdapter.voicemails.firstIndex(where: { $0.id == voicemail.id }) {\nvar updatedVoicemail = viewAdapter.voicemails[index]\nupdatedVoicemail.from?.blackListID = blackListId\nviewAdapter.updateVoicemail(updatedVoicemail)\n}\n}\n}\n}\n}\nprivate func handleMessage(voicemail: VoicemailObject) {\nguard let phoneNumber = voicemail.from?.number else { return }\nprint(“Send message to: (phoneNumber)”)\n// TODO: Implement message functionality\nviewAdapter.handleMessage(phoneNumber: phoneNumber)\n}\n// MARK: - Row Tap Handler\nprivate func handleRowTap(voicemail: VoicemailObject, isSelected: Binding&lt;Bool&gt;, scrollProxy: ScrollViewProxy) {\nif viewState.isEditing {\n// When editing, toggle checkbox selection\nisSelected.wrappedValue.toggle()\n} else {\n// Toggle expansion - List animation handles the smooth movement of rows below\nif viewState.expandedVoicemailId == voicemail.id {\ncollapseExpandedRow()\n} else {\nexpandRow(for: voicemail)\n}\n}\n}\n// MARK: - Expand/Collapse Helpers\nprivate func expandRow(for voicemail: VoicemailObject) {\n// Cleanup previous expanded row\ncleanupExpandedRow()\n// Create new VIPER components\nlet container = InfoPopupDependencyContainer(voicemail: voicemail)\nlet (viewAdapter, presenter, router) = container.assembleModule()\n// Set voicemail update delegate\nif let interactor = presenter.interactor as? InfoPopupInteractor {\ninteractor.voicemailUpdateDelegate = self.viewAdapter.output as? VoicemailUpdateDelegate\n}\n// Set router callbacks\nrouter.onDismiss = {\nself.collapseExpandedRow()\n}\nrouter.onInfoTapped = {\nDispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {\nself.navigationPath.append(voicemail)\n}\n}\nrouter.onShowProgress = { message in\nTask {\nself.viewAdapter.showProgress(message: message)\n}\n}\nrouter.onHideProgress = {\nTask {\nself.viewAdapter.hideProgress()\n}\n}\nrouter.onShareTapped = { activityItems in\nself.showShareSheetModally(activityItems: activityItems)\n}\n// Store references\nself.infoPopupViewAdapter = viewAdapter\nself.infoPopupPresenter = presenter\nself.infoPopupWireframe = router\n// Reset audio output state\nself.viewState.selectedAudioOutput = .speaker\nself.viewState.showAudioOutputMenu = false\n// Set expanded ID - List animation automatically handles row transitions\nself.viewState.expandedVoicemailId = voicemail.id\n}\nprivate func collapseExpandedRow() {\n// Hide menu first\nhideAudioOutputMenuFromWindow()\n// Remove window-level mask views\nif let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\nlet window = windowScene.windows.first {\nlet maskViews = window.subviews.filter { $0.tag == 8888 || $0.tag == 7777 }\nUIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut, animations: {\nmaskViews.forEach { $0.alpha = 0 }\n}) { _ in\nmaskViews.forEach { $0.removeFromSuperview() }\n}\n}\n// Clear expanded ID - List animation automatically handles row transitions\nself.viewState.expandedVoicemailId = nil\n// Cleanup after animation\nDispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {\nself.cleanupExpandedRow()\n}\n}\nprivate func cleanupExpandedRow() {\n// Stop audio playback\nif let interactor = infoPopupPresenter?.interactor as? InfoPopupInteractor {\ninteractor.stopAudio()\n}\n// Ensure audio menu is hidden\nhideAudioOutputMenuFromWindow()\n// Clear references\ninfoPopupViewAdapter = nil\ninfoPopupPresenter = nil\ninfoPopupWireframe = nil\n// Reset audio states\nviewState.showAudioOutputMenu = false\n}\n// MARK: - Visibility and Scroll\nprivate func checkVisibilityAndScroll(voicemailId: String, rowFrame: CGRect, scrollFrame: CGRect, proxy: ScrollViewProxy) {\n// Check if the row is completely visible within the scroll view\nlet isCompletelyVisible = scrollFrame.contains(rowFrame)\nif !isCompletelyVisible {\n// Row is not completely visible, scroll to make it visible\nwithAnimation(.easeInOut(duration: animationDuration)) {\nproxy.scrollTo(voicemailId, anchor: .bottom)\n}\n}\n// If completely visible, do nothing (don’t scroll)\n}\n// MARK: - Update Mask Views\nprivate func updateMaskFrames(rowGeometry: GeometryProxy, scrollGeometry: GeometryProxy) {\nguard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\nlet window = windowScene.windows.first else {\nreturn\n}\n// Get the expanded row’s frame in window coordinates\nlet rowFrame = rowGeometry.frame(in: .global)\nlet windowBounds = window.bounds\n// Remove existing mask views\nwindow.subviews.filter { $0.tag == 8888 || $0.tag == 7777 }.forEach { $0.removeFromSuperview() }\n// Create upper mask view (from top of window to top of row)\nlet upperHeight = rowFrame.minY\nif upperHeight &gt; 0 {\nlet upperMask = createMaskView(frame: CGRect(x: 0, y: 0, width: windowBounds.width, height: upperHeight), tag: 8888)\nwindow.addSubview(upperMask)\n}\n// Create lower mask view (from bottom of row to bottom of window)\nlet lowerY = rowFrame.maxY\nlet lowerHeight = windowBounds.height - lowerY\nif lowerHeight &gt; 0 {\nlet lowerMask = createMaskView(frame: CGRect(x: 0, y: lowerY, width: windowBounds.width, height: lowerHeight), tag: 7777)\nwindow.addSubview(lowerMask)\n}\n// Re-establish z-order if audio menu is showing\n// This ensures backdrop (6665) and menu (6666) stay above masks (7777/8888)\nif viewState.showAudioOutputMenu {\nif let backdropView = window.subviews.first(where: { $0.tag == 6665 }) {\nwindow.bringSubviewToFront(backdropView)\n}\nif let menuView = window.subviews.first(where: { $0.tag == 6666 }) {\nwindow.bringSubviewToFront(menuView)\n}\n}\n}\nprivate func createMaskView(frame: CGRect, tag: Int) -&gt; UIView {\nlet maskView = UIView(frame: frame)\nmaskView.backgroundColor = UIColor.gray.withAlphaComponent(0.2)\nmaskView.tag = tag\nmaskView.isUserInteractionEnabled = true\nlet tapView = TapHandlerView(\nonTap: {\nself.collapseExpandedRow()\n},\nshouldHandle: {\n// Don’t collapse if audio menu or share sheet is showing\n!self.viewState.showAudioOutputMenu &amp;&amp; !self.viewState.showShareSheet\n}\n)\nlet hostingController = UIHostingController(rootView: tapView)\nhostingController.view.backgroundColor = .clear\nhostingController.view.frame = maskView.bounds\nmaskView.addSubview(hostingController.view)\nreturn maskView\n}\n// MARK: - Audio Output Menu\nprivate func showAudioOutputMenuAtWindow() {\nguard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\nlet window = windowScene.windows.first else {\nreturn\n}\n// Remove existing menu if any\nhideAudioOutputMenuFromWindow()\n// Create backdrop\nlet backdropView = BackdropView {\nself.viewState.showAudioOutputMenu = false\n}\nlet backdropController = UIHostingController(rootView: backdropView)\nbackdropController.view.backgroundColor = .clear\nbackdropController.view.frame = window.bounds\nbackdropController.view.tag = 6665\nbackdropController.view.isUserInteractionEnabled = true\n// Store reference to backdrop controller\nbackdropHostingController = backdropController\n// Add backdrop to window (will be above mask views with tags 7777/8888)\nwindow.addSubview(backdropController.view)\n// Bring to front to ensure it’s above mask views\nwindow.bringSubviewToFront(backdropController.view)\n// Get bluetooth availability from InfoPopupPlayerView\n// Check the current audio session for Bluetooth devices\nlet bluetoothAvailable = checkForBluetoothDevice()\n// Create the audio menu view\nlet menuView = AudioOutputMenu(\nselected: $viewState.selectedAudioOutput,\nshow: $viewState.showAudioOutputMenu,\nbluetoothAvailable: bluetoothAvailable\n)\n// Create hosting controller\nlet hostingController = UIHostingController(rootView: menuView)\nhostingController.view.backgroundColor = .clear\nhostingController.view.tag = 6666 // Tag for audio menu\nhostingController.view.isUserInteractionEnabled = true\n// Calculate menu size\nlet targetSize = hostingController.view.intrinsicContentSize\nlet menuWidth = targetSize.width &gt; 0 ? targetSize.width : 200\nlet menuHeight = targetSize.height &gt; 0 ? targetSize.height : (bluetoothAvailable ? 170 : 140)\nlet menuX = viewState.speakerButtonFrame.midX - (menuWidth / 2)\nlet menuY = viewState.speakerButtonFrame.midY + 17\n// Ensure menu stays within window bounds\nlet adjustedX = max(10, min(menuX, window.bounds.width - menuWidth - 10))\nlet adjustedY = max(10, min(menuY, window.bounds.height - menuHeight - 10))\nhostingController.view.frame = CGRect(\nx: adjustedX,\ny: adjustedY,\nwidth: menuWidth,\nheight: menuHeight\n)\n// Add menu to window (above backdrop)\nwindow.addSubview(hostingController.view)\n// Ensure menu is at the very front\nwindow.bringSubviewToFront(hostingController.view)\n// Store reference\naudioMenuHostingController = hostingController\n// Animate appearance\nbackdropController.view.alpha = 0\nhostingController.view.alpha = 0\nhostingController.view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)\nUIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {\nbackdropController.view.alpha = 1\nhostingController.view.alpha = 1\nhostingController.view.transform = .identity\n}\n}\nprivate func checkForBluetoothDevice() -&gt; Bool {\nlet currentSession = AVAudioSession.sharedInstance()\n// Check current route outputs\nfor port in currentSession.currentRoute.outputs {\nif port.portType == .bluetoothHFP ||\nport.portType == .bluetoothA2DP ||\nport.portType == .bluetoothLE {\nreturn true\n}\n}\n// Check available inputs\nif let availableInputs = currentSession.availableInputs {\nfor route in availableInputs {\nif route.portType == .bluetoothHFP ||\nroute.portType == .bluetoothA2DP ||\nroute.portType == .bluetoothLE {\nreturn true\n}\n}\n}\nreturn false\n}\nprivate func hideAudioOutputMenuFromWindow() {\nguard let backdropController = backdropHostingController,\nlet menuController = audioMenuHostingController else {\nreturn\n}\nUIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) {\nbackdropController.view.alpha = 0\nmenuController.view.alpha = 0\nmenuController.view.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)\n} completion: { _ in\n// Reset anchor point before removing\nmenuController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 0.5)\nbackdropController.view.removeFromSuperview()\nmenuController.view.removeFromSuperview()\nself.backdropHostingController = nil\nself.audioMenuHostingController = nil\n}\n}\n// MARK: - Mailbox Filter Menu Window\nprivate func showMailboxFilterMenuAtWindow() {\nguard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\nlet window = windowScene.windows.first else {\nreturn\n}\nhideMailboxFilterMenuFromWindow()\n// Create backdrop\nlet backdropView = BackdropView {\nself.viewState.showMailboxFilterMenu = false\n}\nlet backdropController = UIHostingController(rootView: backdropView)\nbackdropController.view.backgroundColor = .clear\nbackdropController.view.frame = window.bounds\nbackdropController.view.tag = 6667\nbackdropController.view.isUserInteractionEnabled = true\nmailboxBackdropHostingController = backdropController\nwindow.addSubview(backdropController.view)\nwindow.bringSubviewToFront(backdropController.view)\n// Create the mailbox filter menu view\nlet options = [MailboxFilterOption(id: “all”, name: “All Inbox”)] + mailboxOptions\nlet menuView = MailboxFilterMenu(\noptions: options,\nselectedIds: $viewState.selectedMailboxIds,\nonSelect: { optionId in\ntoggleMailboxSelection(optionId)\n}\n)\nlet hostingController = UIHostingController(rootView: menuView)\nhostingController.view.backgroundColor = .clear\nhostingController.view.tag = 6668\nhostingController.view.isUserInteractionEnabled = true\n// Calculate menu size\nlet targetSize = hostingController.view.intrinsicContentSize\nlet menuWidth = targetSize.width &gt; 0 ? targetSize.width : 240\nlet menuHeight = targetSize.height &gt; 0 ? targetSize.height : CGFloat(options.count) * 45\nlet menuX = viewState.mailboxButtonFrame.maxX - menuWidth\nlet menuY = viewState.mailboxButtonFrame.maxY + 8\n// Ensure menu stays within window bounds\nlet adjustedX = max(10, min(menuX, window.bounds.width - menuWidth - 10))\nlet adjustedY = max(10, min(menuY, window.bounds.height - menuHeight - 10))\nhostingController.view.frame = CGRect(\nx: adjustedX,\ny: adjustedY,\nwidth: menuWidth,\nheight: menuHeight\n)\nwindow.addSubview(hostingController.view)\nwindow.bringSubviewToFront(hostingController.view)\nmailboxMenuHostingController = hostingController\n// Animate appearance\nbackdropController.view.alpha = 0\nhostingController.view.alpha = 0\nhostingController.view.transform = CGAffineTransform(scaleX: 0.85, y: 0.85)\nUIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseOut) {\nbackdropController.view.alpha = 1\nhostingController.view.alpha = 1\nhostingController.view.transform = .identity\n}\n}\nprivate func hideMailboxFilterMenuFromWindow() {\nguard let backdropController = mailboxBackdropHostingController,\nlet menuController = mailboxMenuHostingController else {\nreturn\n}\nUIView.animate(withDuration: 0.2, delay: 0, options: [.curveEaseIn]) {\nbackdropController.view.alpha = 0\nmenuController.view.alpha = 0\nmenuController.view.transform = CGAffineTransform(scaleX: 0.85, y: 0.85)\n} completion: { _ in\nbackdropController.view.removeFromSuperview()\nmenuController.view.removeFromSuperview()\nself.mailboxBackdropHostingController = nil\nself.mailboxMenuHostingController = nil\n}\n}\n// MARK: - Share Sheet\nprivate func showShareSheetModally(activityItems: [Any]) {\nguard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\nlet window = windowScene.windows.first,\nlet rootViewController = window.rootViewController else {\nreturn\n}\n// Find the topmost presented view controller\nvar topController = rootViewController\nwhile let presented = topController.presentedViewController {\ntopController = presented\n}\n// Create UIActivityViewController\nlet activityVC = UIActivityViewController(\nactivityItems: activityItems,\napplicationActivities: nil\n)\n// For iPad - set popover presentation\nif let popover = activityVC.popoverPresentationController {\n// Find the share button’s view if possible, otherwise use window center\npopover.sourceView = topController.view\npopover.sourceRect = CGRect(\nx: topController.view.bounds.midX,\ny: topController.view.bounds.midY,\nwidth: 0,\nheight: 0\n)\npopover.permittedArrowDirections = []\n}\n// Set flag\nviewState.showShareSheet = true\n// Dismiss handler\nactivityVC.completionWithItemsHandler = { _, _, _, _ in\nself.viewState.showShareSheet = false\n}\n// Present modally\ntopController.present(activityVC, animated: true)\n}\n// MARK: - Window Level Progress HUD\n@MainActor\nprivate func showWindowLevelProgress(message: String) {\nguard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\nlet window = windowScene.windows.first else { return }\n// Remove existing progress if any\nhideWindowLevelProgress()\n// Create progress HUD view\nlet progressView = ProgressHUDView(message: message)\nlet hostingController = UIHostingController(rootView: progressView)\nhostingController.view.backgroundColor = .clear\nhostingController.view.tag = 8888888 // Unique tag for progress HUD\nhostingController.view.frame = window.bounds\nhostingController.view.isUserInteractionEnabled = true // Block all touches\n// Store reference\nprogressHostingController = hostingController\n// Add to window\nwindow.addSubview(hostingController.view)\n// Ensure it’s at the very front\nwindow.bringSubviewToFront(hostingController.view)\n// Animate appearance\nhostingController.view.alpha = 0\nUIView.animate(withDuration: 0.2) {\nhostingController.view.alpha = 1\n}\n}\n@MainActor\nprivate func hideWindowLevelProgress() {\nguard let progressController = progressHostingController else { return }\nUIView.animate(withDuration: 0.2, animations: {\nprogressController.view.alpha = 0\n}) { _ in\nprogressController.view.removeFromSuperview()\nself.progressHostingController = nil\n}\n}\n@MainActor\nprivate func updateWindowLevelProgress(message: String) {\n// If already showing, just update the message\nif let progressController = progressHostingController {\nlet updatedView = ProgressHUDView(message: message)\nprogressController.rootView = updatedView\n} else {\n// Otherwise show new progress\nshowWindowLevelProgress(message: message)\n}\n}\n// MARK: - Window Level Bottom Bar\n@MainActor\nprivate func showBottomActionBar() {\nguard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,\nlet window = windowScene.windows.first else { return }\n// Check if already exists\nif window.subviews.contains(where: { $0.tag == 9999 }) {\nreturn\n}\n// Create VIPER module\nlet container = BottomActionBarDependencyContainer()\nlet (viewAdapter, bottomPresenter, router) = container.assembleModule()\n// Store reference\nbottomBarPresenter = bottomPresenter\nbottomBarViewAdapter = viewAdapter\n// Set the voicemail update delegate\nif let interactor = bottomPresenter.interactor as? BottomActionBarInteractor,\nlet voicemailPresenter = self.viewAdapter.output as? VoicemailPresenter {\ninteractor.voicemailUpdateDelegate = voicemailPresenter\n}\n// Configure progress callbacks\nbottomPresenter.onShowProgress = { message in\nTask { @MainActor in\nshowWindowLevelProgress(message: message)\n}\n}\nbottomPresenter.onHideProgress = {\nTask { @MainActor in\nhideWindowLevelProgress()\n}\n}\n// Configure router dismiss callback\nrouter.onDismiss = {\nTask { @MainActor in\nself.viewState.isEditing = false\nself.viewState.selectedVoicemails.removeAll()\nself.hideBottomActionBar()\n}\n}\n// Update initial state\nlet isEnabled = !viewState.selectedVoicemails.isEmpty\nbottomPresenter.updateButtonState(isEnabled: isEnabled)\nTask { @MainActor in\nviewAdapter.updateButtonState(isEnabled: isEnabled)\n}\n// Create binding for selected IDs\nlet selectedIdsBinding = Binding&lt;[String]&gt;(\nget: { Array(self.viewState.selectedVoicemails) },\nset: { _ in }\n)\n// Create view with binding\nlet actionBarView = BottomActionBarView(\nviewAdapter: viewAdapter,\nselectedIds: selectedIdsBinding\n)\nlet hostingController = UIHostingController(rootView: actionBarView)\nbottomBarHostingController = hostingController\nhostingController.view.backgroundColor = .clear\nhostingController.view.tag = 9999\nhostingController.view.translatesAutoresizingMaskIntoConstraints = false\nwindow.addSubview(hostingController.view)\nNSLayoutConstraint.activate([\nhostingController.view.leadingAnchor.constraint(equalTo: window.leadingAnchor),\nhostingController.view.trailingAnchor.constraint(equalTo: window.trailingAnchor),\nhostingController.view.bottomAnchor.constraint(equalTo: window.bottomAnchor),\n])\n// Animate in\nhostingController.view.transform = CGAffineTransform(translationX: 0, y: 100)\nUIView.animate(withDuration: 0.3) {\nhostingController.view.transform = .identity\n}\n}\n@MainActor\nprivate func hideBottomActionBar() {\nguard let window = UIApplication.shared.connectedScenes\n.compactMap({ $0 as? UIWindowScene })\n.first?.windows.first else { return }\nwindow.subviews.filter { $0.tag == 9999 }.forEach { view in\nUIView.animate(withDuration: 0.3, animations: {\nview.transform = CGAffineTransform(translationX: 0, y: 100)\n}) { _ in\nview.removeFromSuperview()\n}\n}\nbottomBarPresenter = nil\nbottomBarViewAdapter = nil\nbottomBarHostingController = nil\n}\n@MainActor\nprivate func updateBottomBarState() {\nguard viewState.isEditing else { return }\nlet isEnabled = !viewState.selectedVoicemails.isEmpty\n// Update both presenter and view adapter\nif let presenter = bottomBarPresenter {\npresenter.updateButtonState(isEnabled: isEnabled)\n}\nif let viewAdapter = bottomBarViewAdapter {\nTask { @MainActor in\nviewAdapter.updateButtonState(isEnabled: isEnabled)\n}\n}\n}\n}\n// MARK: - Preview\nstruct VoicemailView_Previews: PreviewProvider {\nstatic var previews: some View {\nlet (view, _, _) = VoicemailWireframe.createModule()\nreturn view\n}\n}\nThe screen has:\n\nA NavigationStack.\nA List containing a large number of voicemail records.\nA voicemailListHeader displayed above the voicemail list.\nAn All Inbox button positioned below the header.\nWhen the user scrolls:The Voicemail header should scroll away and disappear.The All Inbox button should remain pinned/sticky at the top of the list.The voicemail rows should continue scrolling normally underneath it.\nThe list supports:Pull-to-refresh using .refreshableExpanding/collapsing voicemail rowsSelection/editingNavigation to contact detailsExisting toolbar/navigation behaviorLarge datasetsCurrent ProblemThe list contains a large amount of data and scrolling is not completely smooth.When scrolling, especially while scrolling upward, I notice small jerks/stutters. The scrolling sometimes appears to pause for a fraction of a second and then continue.I want this investigated as a performance problem, not simply hidden with animations.What I want you to doPlease act as a senior iOS/SwiftUI performance engineer.Analyze the existing implementationIdentify all potential causes of scrolling stutter, especially:GeometryReaderScrollViewReaderPassing GeometryProxy into every rowExcessive layout recalculationState changes during scrolling.onChange triggered during scrolling.animation(… value:) applied to the entire ListDynamic row height changesComplex row view hierarchyExpensive image loadingAudio-related UI updatesonAppear / onDisappearTasks running inside rowsUnnecessary view recomputationUnstable ForEach identityAny expensive filtering/sorting performed during view renderingAny unnecessary use of GeometryReaderAny unnecessary state updatesKeep ListThe dataset can be very large, so do NOT replace the implementation with a regular ScrollView.Prefer keeping:Listand:Sectionfor the sticky header behavior.Only recommend ScrollView + LazyVStack if you can demonstrate a concrete reason why it is required and will perform better for this specific implementation.Implement the sticky button correctlyThe desired hierarchy is:Voicemail Header↓All Inbox Button↓Voicemail ListInitially:VoicemailAll Inbox────────────────────────────────Voicemail 1Voicemail 2Voicemail 3Voicemail 4…After scrolling:All Inbox────────────────────────────────Voicemail 5Voicemail 6Voicemail 7Voicemail 8…The Voicemail header should disappear naturally while the All Inbox button remains pinned.Use the native SwiftUI List/Section sticky-header mechanism wherever possible instead of implementing a custom floating overlay.Optimize scroll performanceAvoid passing GeometryProxy into every voicemail row unless it is absolutely necessary.If scroll-position detection is required for:viewState.hasScrolledPastHeaderuse the most lightweight approach possible.Do not calculate geometry for every row just to determine whether the main header has disappeared.Review animationThis code may be contributing to the stutter:.animation(.easeInOut(duration: animationDuration),value: viewState.expandedVoicemailId)Determine whether applying animation to the entire List is causing unnecessary layout work.If necessary, move the animation to only the voicemail row/content whose expanded state actually changes.Do not remove the expand/collapse animation if it is required by the UX. Instead, scope it correctly.Preserve existing functionalityDo not break:navigationPathnavigationDestinationrefreshablevoicemail selectioneditingexpanded voicemail statedelete functionalityread/unread functionalitytoolbarprofile actionerror handlingloading stateexisting architectureexisting ViewAdapter/ViewState patternImportantDo not rewrite the entire screen unnecessarily.First identify the performance bottleneck, then make the smallest architectural changes necessary.Please provide:A detailed explanation of what is causing the scrolling stutter.The optimized code.A before/after explanation of the changes.Explanation of why the new implementation is better for a large dataset.Any changes required inside voicemailRow.Any changes required to voicemailListHeader.Any changes required to hasScrolledPastHeader.Explain whether GeometryReader and ScrollViewReader are still required and why.Explain whether .animation(… value: expandedVoicemailId) should remain on the List or move to the individual row.Do not introduce unnecessary third-party libraries.The final implementation should be production-quality SwiftUI code optimized for smooth scrolling with a large voicemail dataset.","datePublished":"2026-08-14T16:21:42.965Z","url":"https://daily.dev/posts/fhwFF24mL#c-dORx841z9","author":{"@type":"Person","name":"Kapil Kumar","url":"https://daily.dev/kapilkumar12","image":"https://lh3.googleusercontent.com/a/ACg8ocKP-72VGESd_Y_XnqcjNIwk7nx0a7vQgA74qO_TZbd2PHhAKeZG=s96-c"}}]}
```

