feat: Add iOS 26 native WebView with floating action buttons and improved header

BookmarkDetailView2 enhancements:
- Implement floating action buttons with iOS 26 GlassEffect
- Buttons appear at 90% reading progress with slide-up animation
- Use GlassEffectContainer with liquid glass interaction effect
- Position buttons in bottom-right corner with spring animation
- Auto-hide when scrolling back above 90%

Header image improvements:
- Use aspect fit with blurred background for better image display
- Prevents random cropping of header images
- Maintains full image visibility while filling header space

Debug-only features:
- Add #if DEBUG wrapper for view toggle buttons
- Toggle between legacy and native WebView only in debug builds

Technical details:
- GlassEffectContainer with 52pt buttons and 31pt icons
- Spring animation (response: 0.6, damping: 0.8)
- Combined move and opacity transitions
- Full screen ScrollView with bottom safe area extension
- Blurred background layer for non-filling images
This commit is contained in:
Ilyas Hallak 2025-10-14 13:53:31 +02:00
parent f302f8800f
commit e61dbc7d72
2 changed files with 91 additions and 69 deletions

View File

@ -197,6 +197,7 @@ struct BookmarkDetailLegacyView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
#if DEBUG
// Toggle button (left) // Toggle button (left)
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
if #available(iOS 26.0, *) { if #available(iOS 26.0, *) {
@ -208,6 +209,7 @@ struct BookmarkDetailLegacyView: View {
} }
} }
} }
#endif
// Top toolbar (right) // Top toolbar (right)
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {

View File

@ -39,6 +39,44 @@ struct BookmarkDetailView2: View {
} }
private var mainView: some View { private var mainView: some View {
content
.navigationBarTitleDisplayMode(.inline)
.toolbar {
toolbarContent
}
.sheet(isPresented: $showingFontSettings) {
fontSettingsSheet
}
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
}
.onChange(of: showingFontSettings) { _, isShowing in
if !isShowing {
Task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: showingLabelsSheet) { _, isShowing in
if !isShowing {
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId)
}
}
private var content: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Progress bar at top // Progress bar at top
ProgressView(value: readingProgress) ProgressView(value: readingProgress)
@ -47,42 +85,50 @@ struct BookmarkDetailView2: View {
// Main scroll content // Main scroll content
scrollViewContent scrollViewContent
} .overlay(alignment: .bottomTrailing) {
.navigationBarTitleDisplayMode(.inline) if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
.toolbar { if readingProgress >= 0.9 {
toolbarContent floatingActionButtons
} .transition(.move(edge: .bottom).combined(with: .opacity))
.toolbarBackgroundVisibility(.hidden, for: .bottomBar) }
.sheet(isPresented: $showingFontSettings) { }
fontSettingsSheet
}
.sheet(isPresented: $showingLabelsSheet) {
BookmarkLabelsView(bookmarkId: bookmarkId, initialLabels: viewModel.bookmarkDetail.labels)
}
.sheet(isPresented: $showingImageViewer) {
ImageViewerView(imageUrl: viewModel.bookmarkDetail.imageUrl)
}
.onChange(of: showingFontSettings) { _, isShowing in
if !isShowing {
Task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
} }
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: readingProgress >= 0.9)
}
}
private var floatingActionButtons: some View {
GlassEffectContainer(spacing: 52.0) {
HStack(spacing: 52.0) {
Button(action: {
Task {
await viewModel.toggleFavorite(id: bookmarkId)
}
}) {
Image(systemName: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star")
.foregroundStyle(viewModel.bookmarkDetail.isMarked ? .yellow : .primary)
.frame(width: 52.0, height: 52.0)
.font(.system(size: 31))
}
.disabled(viewModel.isLoading)
.glassEffect()
Button(action: {
Task {
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
}
}) {
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
.frame(width: 52.0, height: 52.0)
.font(.system(size: 31))
}
.disabled(viewModel.isLoading)
.glassEffect()
.offset(x: -52.0, y: 0.0)
} }
} }
.onChange(of: showingLabelsSheet) { _, isShowing in .padding(.trailing, 1)
if !isShowing { .padding(.bottom, 10)
Task {
await viewModel.refreshBookmarkDetail(id: bookmarkId)
}
}
}
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.task {
await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId)
}
} }
private var scrollViewContent: some View { private var scrollViewContent: some View {
@ -136,7 +182,7 @@ struct BookmarkDetailView2: View {
} }
.coordinateSpace(name: "scrollView") .coordinateSpace(name: "scrollView")
.clipped() .clipped()
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: [.top, .bottom])
.scrollPosition($scrollPosition) .scrollPosition($scrollPosition)
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in .onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
contentEndPosition = endPosition contentEndPosition = endPosition
@ -171,9 +217,10 @@ struct BookmarkDetailView2: View {
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0 let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
readingProgress = progress
if shouldUpdate { if shouldUpdate {
lastSentProgress = progress lastSentProgress = progress
readingProgress = progress
viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil) viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil)
} }
} }
@ -214,40 +261,6 @@ struct BookmarkDetailView2: View {
} }
} }
} }
#if DEBUG
// Bottom toolbar - Archive section
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
ToolbarItemGroup(placement: .bottomBar) {
Spacer()
Button(action: {
Task {
await viewModel.toggleFavorite(id: bookmarkId)
}
}) {
Label(
viewModel.bookmarkDetail.isMarked ? "Favorited" : "Favorite",
systemImage: viewModel.bookmarkDetail.isMarked ? "star.fill" : "star"
)
}
.disabled(viewModel.isLoading)
Button(action: {
Task {
await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
}
}) {
Label(
viewModel.bookmarkDetail.isArchived ? "Unarchive" : "Archive",
systemImage: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox"
)
}
.disabled(viewModel.isLoading)
}
}
#endif
} }
private var fontSettingsSheet: some View { private var fontSettingsSheet: some View {
@ -278,11 +291,18 @@ struct BookmarkDetailView2: View {
private func headerView(width: CGFloat) -> some View { private func headerView(width: CGFloat) -> some View {
if !viewModel.bookmarkDetail.imageUrl.isEmpty { if !viewModel.bookmarkDetail.imageUrl.isEmpty {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
// Background blur for images that don't fill
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: width, height: headerHeight) .frame(width: width, height: headerHeight)
.blur(radius: 30)
.clipped() .clipped()
// Main image with fit
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fit)
.frame(width: width, height: headerHeight)
// Zoom icon // Zoom icon
Button(action: { Button(action: {
showingImageViewer = true showingImageViewer = true