Add camera functionality with CameraX, permissions, watermarks, and settings page

This commit is contained in:
xiajiid
2026-02-08 12:15:14 +08:00
parent 146d60560a
commit ce8c4f51fd
10 changed files with 674 additions and 19 deletions

View File

@@ -1,17 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.example.app">
<!-- 相机权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 位置权限 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 硬件功能声明 -->
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LogCam">
android:theme="@style/Theme.LogCam"
android:requestLegacyExternalStorage="true"
tools:targetApi="q">
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View File

@@ -0,0 +1,283 @@
package com.example.app
import android.Manifest
import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.location.Geocoder
import android.location.Location
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Looper
import android.provider.MediaStore
import android.util.Log
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.google.android.material.snackbar.Snackbar
import java.io.IOException
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import kotlin.math.roundToInt
class CameraActivity : AppCompatActivity() {
companion object {
private const val TAG = "LogCam"
private const val REQUEST_CODE_PERMISSIONS = 10
private val REQUIRED_PERMISSIONS = arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION
)
}
private lateinit var viewFinder: PreviewView
private lateinit var captureButton: ImageButton
private lateinit var settingsButton: ImageButton
private lateinit var photoPreviewLayout: LinearLayout
private var imageCapture: ImageCapture? = null
private lateinit var cameraExecutor: ExecutorService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 初始化视图
viewFinder = findViewById(R.id.viewFinder)
captureButton = findViewById(R.id.captureButton)
settingsButton = findViewById(R.id.settingsButton)
photoPreviewLayout = findViewById(R.id.photoPreviewLayout)
// 请求权限
if (allPermissionsGranted()) {
startCamera()
} else {
ActivityCompat.requestPermissions(
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
)
}
// 设置点击监听器
captureButton.setOnClickListener { takePhoto() }
settingsButton.setOnClickListener { openSettings() }
// 初始化线程池
cameraExecutor = Executors.newSingleThreadExecutor()
}
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
}
private fun startCamera() {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(viewFinder.surfaceProvider)
}
imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
this, cameraSelector, preview, imageCapture
)
} catch (exc: Exception) {
Log.e(TAG, "相机绑定失败", exc)
}
}, ContextCompat.getMainExecutor(this))
}
private fun takePhoto() {
val imageCapture = imageCapture ?: return
val outputFileOptions = ImageCapture.OutputFileOptions.Builder(
createImageUri()
).build()
imageCapture.takePicture(
outputFileOptions,
ContextCompat.getMainExecutor(this),
object : ImageCapture.OnImageSavedCallback {
override fun onError(exception: ImageCaptureException) {
Log.e(TAG, "照片拍摄失败: ${exception.message}", exception)
Toast.makeText(
baseContext,
"照片拍摄失败: ${exception.message}",
Toast.LENGTH_SHORT
).show()
}
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// 添加水印到已保存的照片
val savedUri = output.savedUri ?: return
addWatermarkToImage(savedUri)
Toast.makeText(
baseContext,
"照片已保存",
Toast.LENGTH_SHORT
).show()
// 刷新相册
refreshGallery(savedUri)
}
}
)
}
private fun createImageUri(): Uri {
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "巡检_${System.currentTimeMillis()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
}
}
return contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
?: throw IOException("无法创建图片URI")
}
private fun addWatermarkToImage(imageUri: Uri) {
try {
val inputStream = contentResolver.openInputStream(imageUri)
val originalBitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val watermarkedBitmap = addWatermark(originalBitmap)
// 将带水印的图片保存回原URI
val outputStream = contentResolver.openOutputStream(imageUri, "rwt") // truncate and write
watermarkedBitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
outputStream?.close()
// 回收位图以释放内存
if (watermarkedBitmap != originalBitmap) {
originalBitmap.recycle()
}
watermarkedBitmap.recycle()
} catch (e: Exception) {
Log.e(TAG, "添加水印失败: ${e.message}")
}
}
private fun addWatermark(bitmap: Bitmap): Bitmap {
val watermarkedBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(watermarkedBitmap)
val paint = Paint().apply {
color = Color.WHITE
textSize = 48f
typeface = Typeface.DEFAULT_BOLD
textAlign = Paint.Align.LEFT
}
// 绘制时间水印
val timestamp = formatDate(System.currentTimeMillis())
canvas.drawText(timestamp, 50f, watermarkedBitmap.height - 100f, paint)
// 绘制位置水印
getCurrentLocation()?.let { location ->
val address = getAddressFromLocation(location.latitude, location.longitude)
canvas.drawText(address, 50f, watermarkedBitmap.height - 50f, paint)
}
return watermarkedBitmap
}
private fun refreshGallery(uri: Uri) {
// 通知相册更新
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.IS_PENDING, 0)
}
contentResolver.update(uri, contentValues, null, null)
}
// 将时间戳转换为指定格式的日期时间字符串
fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy年-MM月-dd日 HH:mm:ss", Locale.getDefault())
return sdf.format(Date(timestamp))
}
// 使用Geocoder将经纬度转换为地址
private fun getAddressFromLocation(latitude: Double, longitude: Double): String? {
return try {
val geocoder = Geocoder(this, Locale.getDefault())
val addresses = geocoder.getFromLocation(latitude, longitude, 1)
if (addresses?.isEmpty() == false) {
val address = addresses[0]
val addressLine = address.getAddressLine(0)
addressLine ?: "${latitude}, ${longitude}"
} else {
"${latitude}, ${longitude}"
}
} catch (e: Exception) {
Log.e(TAG, "无法解析地址: ${e.message}")
"${latitude}, ${longitude}"
}
}
@SuppressLint("MissingPermission")
private fun getCurrentLocation(): Location? {
// 这里应该实现获取当前位置的逻辑
// 简化实现返回null实际应用中需要使用LocationManager或FusedLocationProviderClient
return null
}
private fun openSettings() {
// 打开设置页面
val settingsFragment = SettingsFragment()
settingsFragment.show(supportFragmentManager, "SettingsFragment")
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
startCamera()
} else {
Toast.makeText(this, "权限被拒绝,相机功能不可用", Toast.LENGTH_SHORT).show()
// 可以在这里提供降级功能,比如手动输入位置信息
}
}
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
}
}

View File

@@ -1,5 +1,6 @@
package com.example.app
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
@@ -7,5 +8,9 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 直接启动相机Activity
startActivity(Intent(this, CameraActivity::class.java))
finish() // 关闭MainActivity避免返回键回到空白页面
}
}

View File

@@ -0,0 +1,121 @@
package com.example.app
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.fragment.app.DialogFragment
class SettingsFragment : DialogFragment() {
private lateinit var locationCalibrationRadioGroup: RadioGroup
private lateinit var manualLatInput: EditText
private lateinit var manualLngInput: EditText
private lateinit var distanceInput: EditText
private lateinit var saveButton: Button
private lateinit var cancelButton: Button
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_settings, container, false)
initViews(view)
setupClickListeners()
return view
}
private fun initViews(view: View) {
locationCalibrationRadioGroup = view.findViewById(R.id.locationCalibrationRadioGroup)
manualLatInput = view.findViewById(R.id.manualLatInput)
manualLngInput = view.findViewById(R.id.manualLngInput)
distanceInput = view.findViewById(R.id.distanceInput)
saveButton = view.findViewById(R.id.saveButton)
cancelButton = view.findViewById(R.id.cancelButton)
}
private fun setupClickListeners() {
locationCalibrationRadioGroup.setOnCheckedChangeListener { _, checkedId ->
val selectedMethod = when (checkedId) {
R.id.latLngDistanceCalibration -> "经纬度+距离校准"
R.id.onlineQueryCalibration -> "联网查询校准"
else -> "默认"
}
Log.d("SettingsFragment", "选择了校准方式: $selectedMethod")
}
saveButton.setOnClickListener {
saveSettings()
dismiss()
}
cancelButton.setOnClickListener {
dismiss()
}
}
private fun saveSettings() {
// 获取选择的位置校准方式
val selectedMethodId = locationCalibrationRadioGroup.checkedRadioButtonId
val calibrationMethod = when (selectedMethodId) {
R.id.latLngDistanceCalibration -> "lat_lng_distance"
R.id.onlineQueryCalibration -> "online_query"
else -> "default"
}
// 保存设置到SharedPreferences
val sharedPreferences = requireContext().getSharedPreferences("LogCamSettings", Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putString("calibration_method", calibrationMethod)
// 如果选择了手动输入经纬度,则保存经纬度和距离
if (selectedMethodId == R.id.latLngDistanceCalibration) {
try {
val latStr = manualLatInput.text.toString()
val lngStr = manualLngInput.text.toString()
val distanceStr = distanceInput.text.toString()
if (latStr.isNotEmpty() && lngStr.isNotEmpty()) {
val latitude = latStr.toDouble()
val longitude = lngStr.toDouble()
val distance = if (distanceStr.isNotEmpty()) distanceStr.toDouble() else 0.0
if (latitude in -90.0..90.0 && longitude in -180.0..180.0) {
editor.putFloat("manual_latitude", latitude.toFloat())
editor.putFloat("manual_longitude", longitude.toFloat())
editor.putFloat("calibration_distance", distance.toFloat())
Toast.makeText(context, "设置已保存", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "经纬度超出有效范围", Toast.LENGTH_SHORT).show()
return
}
}
} catch (e: NumberFormatException) {
Toast.makeText(context, "请输入有效的数字", Toast.LENGTH_SHORT).show()
return
}
}
editor.apply()
}
companion object {
const val REQUEST_CODE_LOCATION_PERMISSION = 100
}
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#1C1B1F" />
<stroke
android:width="4dp"
android:color="@android:color/white" />
</shape>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0"/>
<path
android:fillColor="@android:color/white"
android:pathData="M9,2L7.17,4L4,4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2L9,2zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.82,11.69 4.82,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

View File

@@ -4,30 +4,73 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="match_height"
tools:context=".MainActivity">
<TextView
android:id="@+id/titleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
<!-- CameraView for displaying the camera preview -->
<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/controlPanel"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/descriptionText"
android:layout_width="wrap_content"
<!-- Control panel for camera controls -->
<LinearLayout
android:id="@+id/controlPanel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="Welcome to LogCam"
android:textSize="16sp"
android:layout_marginTop="16dp"
android:orientation="vertical"
android:padding="16dp"
android:background="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleText" />
app:layout_constraintStart_toStartOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<!-- Capture button -->
<ImageButton
android:id="@+id/captureButton"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_margin="16dp"
android:background="@drawable/circle_button_background"
android:src="@drawable/ic_camera"
android:contentDescription="Capture photo" />
<!-- Settings button -->
<ImageButton
android:id="@+id/settingsButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_settings"
android:contentDescription="Settings" />
</LinearLayout>
<!-- Preview of captured photos -->
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<LinearLayout
android:id="@+id/photoPreviewLayout"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal" />
</HorizontalScrollView>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="设置"
android:textSize="24sp"
android:textStyle="bold"
android:gravity="center"
android:layout_marginBottom="24dp" />
<!-- 地点校准方式 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="地点校准方式"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<RadioGroup
android:id="@+id/locationCalibrationRadioGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp">
<RadioButton
android:id="@+id/latLngDistanceCalibration"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="经纬度 + 距离校准"
android:checked="true" />
<RadioButton
android:id="@+id/onlineQueryCalibration"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="联网查询校准" />
</RadioGroup>
<!-- 手动经纬度输入区域 -->
<LinearLayout
android:id="@+id/manualInputLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="手动输入经纬度"
android:textSize="16sp"
android:layout_marginBottom="8dp" />
<EditText
android:id="@+id/manualLatInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="纬度 (-90 到 90)"
android:inputType="numberSigned|numberDecimal" />
<EditText
android:id="@+id/manualLngInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="经度 (-180 到 180)"
android:inputType="numberSigned|numberDecimal" />
<EditText
android:id="@+id/distanceInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="校准距离 (米)"
android:inputType="numberDecimal" />
</LinearLayout>
<!-- 水印样式设置 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="水印样式"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<Spinner
android:id="@+id/watermarkStyleSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp" />
<!-- 合成质量设置 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="合成质量"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<Spinner
android:id="@+id/qualitySpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp" />
<!-- 按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end">
<Button
android:id="@+id/cancelButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="取消"
android:layout_marginEnd="8dp" />
<Button
android:id="@+id/saveButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="保存" />
</LinearLayout>
</LinearLayout>
</ScrollView>