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:
parent
f302f8800f
commit
e61dbc7d72
@ -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) {
|
||||||
|
|||||||
@ -39,20 +39,11 @@ struct BookmarkDetailView2: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var mainView: some View {
|
private var mainView: some View {
|
||||||
VStack(spacing: 0) {
|
content
|
||||||
// Progress bar at top
|
|
||||||
ProgressView(value: readingProgress)
|
|
||||||
.progressViewStyle(LinearProgressViewStyle())
|
|
||||||
.frame(height: 3)
|
|
||||||
|
|
||||||
// Main scroll content
|
|
||||||
scrollViewContent
|
|
||||||
}
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
toolbarContent
|
toolbarContent
|
||||||
}
|
}
|
||||||
.toolbarBackgroundVisibility(.hidden, for: .bottomBar)
|
|
||||||
.sheet(isPresented: $showingFontSettings) {
|
.sheet(isPresented: $showingFontSettings) {
|
||||||
fontSettingsSheet
|
fontSettingsSheet
|
||||||
}
|
}
|
||||||
@ -85,6 +76,61 @@ struct BookmarkDetailView2: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var content: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Progress bar at top
|
||||||
|
ProgressView(value: readingProgress)
|
||||||
|
.progressViewStyle(LinearProgressViewStyle())
|
||||||
|
.frame(height: 3)
|
||||||
|
|
||||||
|
// Main scroll content
|
||||||
|
scrollViewContent
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.trailing, 1)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
|
||||||
private var scrollViewContent: some View {
|
private var scrollViewContent: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user