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)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
#if DEBUG
// Toggle button (left)
ToolbarItem(placement: .navigationBarLeading) {
if #available(iOS 26.0, *) {
@ -208,6 +209,7 @@ struct BookmarkDetailLegacyView: View {
}
}
}
#endif
// Top toolbar (right)
ToolbarItem(placement: .navigationBarTrailing) {

View File

@ -39,6 +39,44 @@ struct BookmarkDetailView2: 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) {
// Progress bar at top
ProgressView(value: readingProgress)
@ -47,42 +85,50 @@ struct BookmarkDetailView2: View {
// Main scroll content
scrollViewContent
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
toolbarContent
}
.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)
.overlay(alignment: .bottomTrailing) {
if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
if readingProgress >= 0.9 {
floatingActionButtons
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
.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
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)
}
.padding(.trailing, 1)
.padding(.bottom, 10)
}
private var scrollViewContent: some View {
@ -136,7 +182,7 @@ struct BookmarkDetailView2: View {
}
.coordinateSpace(name: "scrollView")
.clipped()
.ignoresSafeArea(edges: .top)
.ignoresSafeArea(edges: [.top, .bottom])
.scrollPosition($scrollPosition)
.onPreferenceChange(ContentHeightPreferenceKey.self) { endPosition in
contentEndPosition = endPosition
@ -171,9 +217,10 @@ struct BookmarkDetailView2: View {
let reachedEnd = progress >= 1.0 && lastSentProgress < 1.0
let shouldUpdate = abs(progress - lastSentProgress) >= threshold || reachedEnd
readingProgress = progress
if shouldUpdate {
lastSentProgress = progress
readingProgress = progress
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 {
@ -278,11 +291,18 @@ struct BookmarkDetailView2: View {
private func headerView(width: CGFloat) -> some View {
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
ZStack(alignment: .bottomTrailing) {
// Background blur for images that don't fill
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fill)
.frame(width: width, height: headerHeight)
.blur(radius: 30)
.clipped()
// Main image with fit
CachedAsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl))
.aspectRatio(contentMode: .fit)
.frame(width: width, height: headerHeight)
// Zoom icon
Button(action: {
showingImageViewer = true