Merge pull request #3796: Fix AttributeGraph cycle on Profile View

Fix AttributeGraph cycle on Profile View
This commit is contained in:
Daniel D’Aquino
2026-06-03 11:26:31 -07:00
committed by GitHub
+42 -25
View File
@@ -102,7 +102,9 @@ struct ProfileView: View {
colorScheme == .light ? DamusColors.white : DamusColors.black colorScheme == .light ? DamusColors.white : DamusColors.black
} }
func bannerBlurViewOpacity() -> Double { /// Returns the blur opacity for the collapsing banner based on the current scroll offset and top safe-area inset.
func bannerBlurViewOpacity(topSafeAreaInset: CGFloat) -> Double {
let navbarHeight = navbarHeight(topSafeAreaInset: topSafeAreaInset)
let progress = -(yOffset + navbarHeight) / 100 let progress = -(yOffset + navbarHeight) / 100
return Double(-yOffset > navbarHeight ? progress : 0) return Double(-yOffset > navbarHeight ? progress : 0)
} }
@@ -114,8 +116,9 @@ struct ProfileView: View {
return (displayName, "@\(userName)") return (displayName, "@\(userName)")
} }
func showFollowBtnInBlurrBanner() -> Bool { /// Determines whether the follow button should appear in the collapsed banner state.
damus_state.contacts.follow_state(profile.pubkey) == .unfollows && bannerBlurViewOpacity() > 1.0 func showFollowBtnInBlurrBanner(topSafeAreaInset: CGFloat) -> Bool {
damus_state.contacts.follow_state(profile.pubkey) == .unfollows && bannerBlurViewOpacity(topSafeAreaInset: topSafeAreaInset) > 1.0
} }
/// Builds the profile timeline filter, while ensuring the user's own profile bypasses NSFW and hashtag-spam content filters. /// Builds the profile timeline filter, while ensuring the user's own profile bypasses NSFW and hashtag-spam content filters.
@@ -133,10 +136,12 @@ struct ProfileView: View {
return ContentFilters(filters: filters).filter return ContentFilters(filters: filters).filter
} }
var bannerSection: some View { /// Builds the stretching and collapsing banner while avoiding synchronous window safe-area queries on the main thread.
func bannerSection(topSafeAreaInset: CGFloat) -> some View {
GeometryReader { proxy -> AnyView in GeometryReader { proxy -> AnyView in
let minY = proxy.frame(in: .global).minY let minY = proxy.frame(in: .global).minY
let navbarHeight = navbarHeight(topSafeAreaInset: topSafeAreaInset)
let blurOpacity = bannerBlurViewOpacity(topSafeAreaInset: topSafeAreaInset)
DispatchQueue.main.async { DispatchQueue.main.async {
self.yOffset = minY self.yOffset = minY
@@ -150,10 +155,10 @@ struct ProfileView: View {
.frame(width: proxy.size.width, height: minY > 0 ? bannerHeight + minY : bannerHeight) .frame(width: proxy.size.width, height: minY > 0 ? bannerHeight + minY : bannerHeight)
.clipped() .clipped()
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)).opacity(bannerBlurViewOpacity()) VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)).opacity(blurOpacity)
} }
Divider().opacity(bannerBlurViewOpacity()) Divider().opacity(blurOpacity)
} }
.frame(height: minY > 0 ? bannerHeight + minY : nil) .frame(height: minY > 0 ? bannerHeight + minY : nil)
.offset(y: minY > 0 ? -minY : -minY < navbarHeight ? 0 : -minY - navbarHeight) .offset(y: minY > 0 ? -minY : -minY < navbarHeight ? 0 : -minY - navbarHeight)
@@ -164,8 +169,9 @@ struct ProfileView: View {
.allowsHitTesting(false) .allowsHitTesting(false)
} }
var navbarHeight: CGFloat { /// Computes the profile navigation height using the view's current safe-area context instead of querying the key window during SwiftUI updates.
return 100.0 - (Theme.safeAreaInsets?.top ?? 0) func navbarHeight(topSafeAreaInset: CGFloat) -> CGFloat {
100.0 - topSafeAreaInset
} }
func navImage(img: String) -> some View { func navImage(img: String) -> some View {
@@ -299,27 +305,30 @@ struct ProfileView: View {
} }
} }
func pfpOffset() -> CGFloat { /// Returns the avatar's vertical offset during banner collapse.
let progress = -yOffset / navbarHeight func pfpOffset(topSafeAreaInset: CGFloat) -> CGFloat {
let progress = -yOffset / navbarHeight(topSafeAreaInset: topSafeAreaInset)
let offset = (pfp_size / 4.0) * (progress < 1.0 ? progress : 1) let offset = (pfp_size / 4.0) * (progress < 1.0 ? progress : 1)
return offset > 0 ? offset : 0 return offset > 0 ? offset : 0
} }
func pfpScale() -> CGFloat { /// Returns the avatar's scale during banner collapse.
let progress = -yOffset / navbarHeight func pfpScale(topSafeAreaInset: CGFloat) -> CGFloat {
let progress = -yOffset / navbarHeight(topSafeAreaInset: topSafeAreaInset)
let scale = 1.0 - (0.5 * (progress < 1.0 ? progress : 1)) let scale = 1.0 - (0.5 * (progress < 1.0 ? progress : 1))
return scale < 1 ? scale : 1 return scale < 1 ? scale : 1
} }
func nameSection(ndbprofile: Profile?, lnurl: String?) -> some View { /// Builds the name and avatar section using geometry-provided safe-area values to avoid AttributeGraph cycles.
func nameSection(ndbprofile: Profile?, lnurl: String?, topSafeAreaInset: CGFloat) -> some View {
return Group { return Group {
let follows_you = profile.pubkey != damus_state.pubkey && profile.follows(pubkey: damus_state.pubkey) let follows_you = profile.pubkey != damus_state.pubkey && profile.follows(pubkey: damus_state.pubkey)
HStack(alignment: .center) { HStack(alignment: .center) {
ProfilePicView(pubkey: profile.pubkey, size: pfp_size, highlight: .custom(imageBorderColor(), 4.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state) ProfilePicView(pubkey: profile.pubkey, size: pfp_size, highlight: .custom(imageBorderColor(), 4.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, damusState: damus_state)
.padding(.top, -(pfp_size / 2.0)) .padding(.top, -(pfp_size / 2.0))
.offset(y: pfpOffset()) .offset(y: pfpOffset(topSafeAreaInset: topSafeAreaInset))
.scaleEffect(pfpScale()) .scaleEffect(pfpScale(topSafeAreaInset: topSafeAreaInset))
.onTapGesture { .onTapGesture {
is_zoomed.toggle() is_zoomed.toggle()
} }
@@ -357,12 +366,13 @@ struct ProfileView: View {
} }
} }
var aboutSection: some View { /// Builds the profile summary section using safe-area values from the surrounding geometry.
func aboutSection(topSafeAreaInset: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 8.0) { VStack(alignment: .leading, spacing: 8.0) {
let lnurl = try? damus_state.profiles.lookup_lnurl(profile.pubkey) let lnurl = try? damus_state.profiles.lookup_lnurl(profile.pubkey)
let ndbprofile = try? damus_state.profiles.lookup(id: profile.pubkey) let ndbprofile = try? damus_state.profiles.lookup(id: profile.pubkey)
nameSection(ndbprofile: ndbprofile, lnurl: lnurl) nameSection(ndbprofile: ndbprofile, lnurl: lnurl, topSafeAreaInset: topSafeAreaInset)
if let about = ndbprofile?.about { if let about = ndbprofile?.about {
AboutView(state: damus_state, about: about) AboutView(state: damus_state, about: about)
@@ -451,14 +461,20 @@ struct ProfileView: View {
} }
var body: some View { var body: some View {
GeometryReader { geometry in
let safeAreaInsets = geometry.safeAreaInsets
let topSafeAreaInset = safeAreaInsets.top
let navbarHeight = navbarHeight(topSafeAreaInset: topSafeAreaInset)
let blurOpacity = bannerBlurViewOpacity(topSafeAreaInset: topSafeAreaInset)
ZStack { ZStack {
ScrollView(.vertical) { ScrollView(.vertical) {
VStack(spacing: 0) { VStack(spacing: 0) {
bannerSection bannerSection(topSafeAreaInset: topSafeAreaInset)
.zIndex(1) .zIndex(1)
VStack() { VStack {
aboutSection aboutSection(topSafeAreaInset: topSafeAreaInset)
VStack(spacing: 0) { VStack(spacing: 0) {
CustomPicker(tabs: tabs, selection: $filter_state) CustomPicker(tabs: tabs, selection: $filter_state)
@@ -477,11 +493,11 @@ struct ProfileView: View {
InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.conversations)) InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.conversations))
} }
} }
.padding(.horizontal, Theme.safeAreaInsets?.left) .padding(.horizontal, safeAreaInsets.leading)
.zIndex(-yOffset > navbarHeight ? 0 : 1) .zIndex(-yOffset > navbarHeight ? 0 : 1)
} }
} }
.padding(.bottom, tabHeight + getSafeAreaBottom()) .padding(.bottom, tabHeight + safeAreaInsets.bottom)
.ignoresSafeArea() .ignoresSafeArea()
.navigationTitle("") .navigationTitle("")
.navigationBarBackButtonHidden() .navigationBarBackButtonHidden()
@@ -499,14 +515,14 @@ struct ProfileView: View {
.font(.subheadline) .font(.subheadline)
.foregroundColor(.white.opacity(0.8)) .foregroundColor(.white.opacity(0.8))
} }
.opacity(bannerBlurViewOpacity()) .opacity(blurOpacity)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, max(5, 15 + (yOffset / 30))) .padding(.top, max(5, 15 + (yOffset / 30)))
} }
} }
.hideToolbarBackground() .hideToolbarBackground()
if showFollowBtnInBlurrBanner() { if showFollowBtnInBlurrBanner(topSafeAreaInset: topSafeAreaInset) {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
FollowButtonView( FollowButtonView(
target: profile.get_follow_target(), target: profile.get_follow_target(),
@@ -556,6 +572,7 @@ struct ProfileView: View {
} }
} }
} }
}
struct ProfileView_Previews: PreviewProvider { struct ProfileView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {