feat(mobile): enhance web view navigation and header interactions

- Add ModalWebViewController and WebViewController for improved web browsing
- Implement WebViewManager method to push modal web views
- Create UINavigationHeaderActionButton for consistent header button styling
- Update header components to use new action button and back button components
- Refactor navigation header and scroll view interactions

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-02-27 19:44:34 +08:00
parent 97dda3ed7d
commit 6c2bc6633e
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
11 changed files with 243 additions and 94 deletions

View File

@ -27,6 +27,13 @@ class ModalWebViewController: UIViewController {
setupNavigationBar()
setupWebView()
loadContent()
setupInteractivePopGesture()
}
private func setupInteractivePopGesture() {
navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}
private func setupNavigationBar() {
@ -55,6 +62,7 @@ class ModalWebViewController: UIViewController {
private func setupWebView() {
view.addSubview(webView)
view.backgroundColor = .systemBackground
webView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.left.right.bottom.equalToSuperview()
@ -81,3 +89,10 @@ extension ModalWebViewController: WKNavigationDelegate {
navigationItem.title = webView.title
}
}
// MARK: - UIGestureRecognizerDelegate
extension ModalWebViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}

View File

@ -0,0 +1,150 @@
//
// WebViewController.swift
// FollowNative
//
// Created by Innei on 2025/2/27.
//
import Foundation
import SnapKit
import UIKit
import WebKit
class WebViewController: UIViewController {
private let webView: WKWebView
private let url: URL
init(url: URL) {
self.url = url
let configuration = WKWebViewConfiguration()
self.webView = WKWebView(frame: .zero, configuration: configuration)
super.init(nibName: nil, bundle: nil)
webView.navigationDelegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupWebView()
setupToolbar()
loadContent()
setupInteractivePopGesture()
}
private func setupInteractivePopGesture() {
navigationController?.interactivePopGestureRecognizer?.delegate = self
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}
private func setupWebView() {
view.addSubview(webView)
view.backgroundColor = .systemBackground
webView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.left.right.bottom.equalToSuperview()
}
}
private func setupToolbar() {
//
navigationController?.isToolbarHidden = false
//
let backButton = UIBarButtonItem(
image: UIImage(systemName: "chevron.backward"), style: .plain, target: self,
action: #selector(goBack))
backButton.tintColor = Utils.accentColor
let forwardButton = UIBarButtonItem(
image: UIImage(systemName: "chevron.forward"), style: .plain, target: self,
action: #selector(goForward))
forwardButton.tintColor = Utils.accentColor
let refreshButton = UIBarButtonItem(
barButtonSystemItem: .refresh, target: self, action: #selector(refreshPage))
refreshButton.tintColor = Utils.accentColor
let safariButton = UIBarButtonItem(
image: UIImage(systemName: "safari"), style: .plain, target: self,
action: #selector(openInSafari))
safariButton.tintColor = Utils.accentColor
// 使
let flexSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
//
toolbarItems = [
flexSpace, backButton, flexSpace, forwardButton, flexSpace, refreshButton, flexSpace,
safariButton, flexSpace,
]
}
private func loadContent() {
let request = URLRequest(url: url)
webView.load(request)
}
@objc private func openInSafari() {
UIApplication.shared.open(url)
}
@objc private func goBack() {
if webView.canGoBack {
webView.goBack()
}
}
@objc private func goForward() {
if webView.canGoForward {
webView.goForward()
}
}
@objc private func refreshPage() {
webView.reload()
}
}
// MARK: - WKNavigationDelegate
extension WebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
navigationItem.title = webView.title
// 退/
updateToolbarButtonsState()
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
// 退/
updateToolbarButtonsState()
}
private func updateToolbarButtonsState() {
// 退
if let items = toolbarItems {
// 退1
if let backButton = items[1] as? UIBarButtonItem {
backButton.isEnabled = webView.canGoBack
}
// 3
if let forwardButton = items[3] as? UIBarButtonItem {
forwardButton.isEnabled = webView.canGoForward
}
}
}
}
// MARK: - UIGestureRecognizerDelegate
extension WebViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}

View File

@ -16,8 +16,8 @@ public class HelperModule: Module {
return
}
DispatchQueue.main.async {
guard let rootVC = UIApplication.shared.windows.first?.rootViewController else { return }
WebViewManager.presentModalWebView(url: url, from: rootVC)
// guard let rootVC = UIApplication.shared.windows.first?.rootViewController else { return }
// WebViewManager.pushModalWebView(url: url, from: rootVC)
}
}

View File

@ -175,6 +175,11 @@ enum WebViewManager {
navController.modalPresentationStyle = .fullScreen
viewController.present(navController, animated: true)
}
static func pushModalWebView(url: URL, from navigationController: UINavigationController) {
let modalVC = ModalWebViewController(url: url)
navigationController.pushViewController(modalVC, animated: true)
}
}
private class WebViewDelegate: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate

View File

@ -147,10 +147,12 @@ export const NavigationHeader = ({
const setHeaderHeight = useContext(SetNavigationHeaderHeightContext)
useEffect(() => {
const id = scrollY.addListener(({ value }) => {
const handler = ({ value }: { value: number }) => {
opacityAnimated.value = Math.max(0, Math.min(1, (value + blurThreshold) / 10))
})
}
const id = scrollY.addListener(handler)
handler({ value: (scrollY as any)._value })
return () => {
scrollY.removeListener(id)
}
@ -191,7 +193,7 @@ export const NavigationHeader = ({
}
}, [navigation, title])
const HeaderLeft = headerLeft ?? DefaultHeaderLeft
const HeaderLeft = headerLeft ?? DefaultHeaderBackButton
const renderTitle = customHeaderTitle ?? HeaderTitle
const headerTitle =
@ -306,14 +308,27 @@ export const NavigationHeader = ({
)
}
const DefaultHeaderLeft = ({ canGoBack }: { canGoBack: boolean }) => {
export const DefaultHeaderBackButton = ({ canGoBack }: { canGoBack: boolean }) => {
const label = useColor("label")
if (!canGoBack) return null
return (
<TouchableOpacity hitSlop={10} onPress={() => router.back()}>
<UINavigationHeaderActionButton onPress={() => router.back()}>
<MingcuteLeftLineIcon height={20} width={20} color={label} />
</TouchableOpacity>
</UINavigationHeaderActionButton>
)
}
export const UINavigationHeaderActionButton = ({
children,
onPress,
}: {
children: ReactNode
onPress?: () => void
}) => {
return (
<TouchableOpacity hitSlop={5} className="p-2" onPress={onPress}>
{children}
</TouchableOpacity>
)
}
const Noop = () => null

View File

@ -1,21 +1,19 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { getDefaultHeaderHeight } from "@react-navigation/elements"
import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"
import { router, Stack, useNavigation } from "expo-router"
import { Stack } from "expo-router"
import type { FC, PropsWithChildren } from "react"
import { useContext, useEffect, useMemo, useRef, useState } from "react"
import type { ScrollView, ScrollViewProps } from "react-native"
import { Animated as RNAnimated, TouchableOpacity, useAnimatedValue, View } from "react-native"
import { Animated as RNAnimated, useAnimatedValue, View } from "react-native"
import type { ReanimatedScrollEvent } from "react-native-reanimated/lib/typescript/hook/commonTypes"
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
import { useColor } from "react-native-uikit-colors"
import {
AttachNavigationScrollViewContext,
SetAttachNavigationScrollViewContext,
} from "@/src/components/layouts/tabbar/contexts/AttachNavigationScrollViewContext"
import { useBottomTabBarHeight } from "@/src/components/layouts/tabbar/hooks"
import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line"
import { AnimatedScrollView } from "../../common/AnimatedComponents"
import { NavigationHeader } from "../header/NavigationHeader"
@ -104,8 +102,6 @@ export const NavigationBlurEffectHeader = ({
headerHideableBottom?: () => React.ReactNode
headerTitleAbsolute?: boolean
}) => {
const canBack = useNavigation().canGoBack()
const navigationContext = useContext(NavigationContext)!
const setHeaderHeight = useContext(SetNavigationHeaderHeightContext)
@ -119,7 +115,7 @@ export const NavigationBlurEffectHeader = ({
headerTransparent: true,
headerShown: true,
headerLeft: headerLeft ?? (canBack ? () => <NavigationHeaderBackButton /> : undefined),
headerLeft,
header: useTypeScriptHappyCallback(
({ options }) => (
@ -147,15 +143,3 @@ export const NavigationBlurEffectHeader = ({
/>
)
}
export const NavigationHeaderBackButton = () => {
return <NavigationHeaderBackButtonImpl />
}
const NavigationHeaderBackButtonImpl = () => {
const label = useColor("label")
return (
<TouchableOpacity hitSlop={10} onPress={() => router.back()}>
<MingcuteLeftLineIcon height={20} width={20} color={label} />
</TouchableOpacity>
)
}

View File

@ -11,14 +11,13 @@ import Animated, {
useSharedValue,
withTiming,
} from "react-native-reanimated"
import { useColor } from "react-native-uikit-colors"
import { useUISettingKey } from "@/src/atoms/settings/ui"
import { DefaultHeaderBackButton } from "@/src/components/layouts/header/NavigationHeader"
import { NavigationContext } from "@/src/components/layouts/views/NavigationContext"
import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line"
import { apiClient } from "@/src/lib/api-fetch"
import { EntryContentContext, useEntryContentContext } from "@/src/modules/entry-content/ctx"
import { EntryContentHeaderRightActions } from "@/src/modules/entry-content/EntryContentHeaderRightActions"
@ -197,8 +196,6 @@ interface EntryLeftGroupProps {
}
const EntryLeftGroup = ({ canGoBack, entryId, titleOpacityShareValue }: EntryLeftGroupProps) => {
const label = useColor("label")
const hideRecentReader = useUISettingKey("hideRecentReader")
const animatedOpacity = useAnimatedStyle(() => {
return {
@ -208,7 +205,7 @@ const EntryLeftGroup = ({ canGoBack, entryId, titleOpacityShareValue }: EntryLef
return (
<View className="flex-row items-center justify-center">
<TouchableOpacity hitSlop={10} onPress={() => router.back()}>
{canGoBack && <MingcuteLeftLineIcon height={20} width={20} color={label} />}
<DefaultHeaderBackButton canGoBack={canGoBack} />
</TouchableOpacity>
{!hideRecentReader && (

View File

@ -1,11 +1,12 @@
import { router } from "expo-router"
import { createContext, createElement, useCallback, useContext, useMemo } from "react"
import type { ListRenderItem } from "react-native"
import { ActivityIndicator, Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"
import { ActivityIndicator, Image, StyleSheet, Text, View } from "react-native"
import Animated, { LinearTransition } from "react-native-reanimated"
import { useColor } from "react-native-uikit-colors"
import { Balance } from "@/src/components/common/Balance"
import { UINavigationHeaderActionButton } from "@/src/components/layouts/header/NavigationHeader"
import {
NavigationBlurEffectHeader,
SafeNavigationScrollView,
@ -99,9 +100,9 @@ export const ListsScreen = () => {
const AddListButton = () => {
const labelColor = useColor("label")
return (
<TouchableOpacity hitSlop={10} onPress={() => router.push("/list")}>
<UINavigationHeaderActionButton onPress={() => router.push("/list")}>
<AddCuteReIcon height={20} width={20} color={labelColor} />
</TouchableOpacity>
</UINavigationHeaderActionButton>
)
}

View File

@ -6,6 +6,7 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState } from
import { Text, View } from "react-native"
import { ModalHeaderSubmitButton } from "@/src/components/common/ModalSharedComponents"
import { UINavigationHeaderActionButton } from "@/src/components/layouts/header/NavigationHeader"
import {
NavigationBlurEffectHeader,
SafeNavigationScrollView,
@ -47,7 +48,10 @@ export const ManageListScreen = ({
const list = useList(id)
return (
<SafeNavigationScrollView className="bg-system-grouped-background mt-6">
<SafeNavigationScrollView
className="bg-system-grouped-background"
contentContainerClassName="mt-6"
>
<NavigationBlurEffectHeader title={`Manage List - ${list?.title}`} />
{!!list && <ListImpl id={list.id} />}
@ -86,21 +90,23 @@ const ListImpl: React.FC<{ id: string }> = ({ id }) => {
<ManageListContext.Provider value={ctxValue}>
<NavigationBlurEffectHeader
headerRight={() => (
<ModalHeaderSubmitButton
isLoading={addFeedsToFeedListMutation.isPending}
isValid
onPress={() => {
addFeedsToFeedListMutation
.mutateAsync()
.then(() => {
router.back()
})
.catch((error) => {
toast.error(getBizFetchErrorMessage(error))
console.error(error)
})
}}
/>
<UINavigationHeaderActionButton>
<ModalHeaderSubmitButton
isLoading={addFeedsToFeedListMutation.isPending}
isValid
onPress={() => {
addFeedsToFeedListMutation
.mutateAsync()
.then(() => {
router.back()
})
.catch((error) => {
toast.error(getBizFetchErrorMessage(error))
console.error(error)
})
}}
/>
</UINavigationHeaderActionButton>
)}
/>
<GroupedInsetListSectionHeader label="Select feeds to add to the current list" />

View File

@ -1,13 +1,7 @@
import { useMutation } from "@tanstack/react-query"
import { router } from "expo-router"
import { useMemo, useRef } from "react"
import {
Text,
TouchableOpacity,
TouchableWithoutFeedback,
useAnimatedValue,
View,
} from "react-native"
import { Text, TouchableWithoutFeedback, useAnimatedValue, View } from "react-native"
import { KeyboardController } from "react-native-keyboard-controller"
import type { OtpInputRef } from "react-native-otp-entry"
import { OtpInput } from "react-native-otp-entry"
@ -15,7 +9,6 @@ import { useColor } from "react-native-uikit-colors"
import { NavigationContext } from "@/src/components/layouts/views/NavigationContext"
import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView"
import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line"
import { twoFactor } from "@/src/lib/auth"
import { queryClient } from "@/src/lib/query-client"
import { toast } from "@/src/lib/toast"
@ -55,18 +48,7 @@ export default function TwoFactorAuthScreen() {
return (
<NavigationContext.Provider value={useMemo(() => ({ scrollY }), [scrollY])}>
<View className="flex-1 p-safe">
<NavigationBlurEffectHeader
headerShown
headerTitle=""
title="2FA"
headerLeft={() => {
return (
<TouchableOpacity onPress={() => router.back()}>
<MingcuteLeftLineIcon color={label} />
</TouchableOpacity>
)
}}
/>
<NavigationBlurEffectHeader headerShown headerTitle="" title="2FA" />
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()

View File

@ -1,9 +1,11 @@
import { router, Stack, useNavigation } from "expo-router"
import { TouchableOpacity, View } from "react-native"
import { useColor } from "react-native-uikit-colors"
import { getDefaultHeaderHeight } from "@react-navigation/elements"
import { useMemo } from "react"
import { useAnimatedValue, View } from "react-native"
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
import { NavigationContext } from "@/src/components/layouts/views/NavigationContext"
import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView"
import { Markdown } from "@/src/components/ui/typography/Markdown"
import { MingcuteLeftLineIcon } from "@/src/icons/mingcute_left_line"
const txt = `# Terms of Service
@ -93,26 +95,18 @@ export const TermsMarkdown = () => {
}
export default function Teams() {
const canGoBack = useNavigation().canGoBack()
const label = useColor("label")
const scrollY = useAnimatedValue(100)
const insets = useSafeAreaInsets()
const frame = useSafeAreaFrame()
const headerHeight = getDefaultHeaderHeight(frame, false, insets.top)
return (
<View className="flex-1">
<Stack.Screen
options={{
headerBackTitle: "Login",
headerTitle: "Terms of Service",
headerShown: true,
headerLeft: canGoBack
? () => (
<TouchableOpacity hitSlop={10} onPress={() => router.back()}>
<MingcuteLeftLineIcon height={20} width={20} color={label} />
</TouchableOpacity>
)
: undefined,
}}
/>
<NavigationContext.Provider value={useMemo(() => ({ scrollY }), [scrollY])}>
<View className="flex-1">
<NavigationBlurEffectHeader headerShown title="Terms of Service" />
<TermsMarkdown />
</View>
<View style={{ height: headerHeight }} />
<TermsMarkdown />
</View>
</NavigationContext.Provider>
)
}