Những điểm chính
Pinch-to-Zoom (chụm) là cử chỉ đa chạm sử dụng hai ngón tay để thay đổi tỷ lệ của nội dung được hiển thị. Khi chụm ngón tay vào (pinch close), tỷ lệ giảm; khi xoà ra (pinch open), tỷ lệ tăng. Cử chỉ là liên tục: hệ thống theo dõi khoảng cách giữa các ngón tay trong thời gian thực và tạo các sự kiện trung gian, cho phép ứng dụng cập nhật tỷ lệ một cách mượt mà.
Cơ chế của cử chỉ dựa trên việc tính khoảng cách giữa hai điểm chạm. iOS và Android sử dụng khoảng cách Euclid: sqrt((x2−x1)² + (y2−y1)²). Tỷ lệ hiện tại được tính là tỷ lệ giữa khoảng cách hiện tại và khoảng cách ban đầu. Nếu khoảng cách ban đầu là 100px và trở thành 150px — tỷ lệ = 1.5 (tăng 50%). iOS truyền tỷ lệ tương đối so với trạng thái trước (delta), Android truyền tỷ lệ tích lũy qua ScaleGestureDetector.
Theo Google Material Design Guidelines, Pinch-to-Zoom là bắt buộc đối với các ứng dụng hiển thị hình ảnh, bản đồ hoặc tài liệu. Tại IT Sectr, chúng tôi triển khai cử chỉ này với UIPinchGestureRecognizer và ScaleGestureDetector trong tất cả các dự án làm việc với nội dung trực quan — từ thư viện ảnh đến trình chỉnh sửa hình ảnh.
UIPinchGestureRecognizer là lớp con liên tục của UIGestureRecognizer để nhận dạng cử chỉ chụm. Không giống các bộ nhận dạng rời rạc (UITapGestureRecognizer), nó tạo ra một chuỗi các sự kiện: .began (ngón tay chạm vào màn hình), .changed (mỗi chuyển động của ngón tay), .ended (ngón tay được nhấc lên).
Các thuộc tính chính: scale (CGFloat) — thay đổi tương đối về khoảng cách giữa các ngón tay kể từ khi bắt đầu cử chỉ; velocity (CGFloat) — tốc độ thay đổi tỷ lệ trên giây. Tỷ lệ ban đầu = 1.0. Khi xoà ngón tay, tỷ lệ tăng (1.0 → 1.5 → 2.0); khi chụm vào, giảm (1.0 → 0.8 → 0.5). Thuộc tính velocity được sử dụng cho quán tính: sau .ended, bạn có thể tạo hoạt ảnh tiếp tục thay đổi tỷ lệ với giảm tốc.
UIPinchGestureRecognizer hỗ trợ thuộc tính delegate để nhận dạng đồng thời với các bộ nhận dạng khác (ví dụ, pan + pinch trên bản đồ). Phương thức gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) trả về true và cả hai cử chỉ hoạt động song song. Apple khuyến nghị thêm UIPinchGestureRecognizer vào view chứa thay vì các phần tử riêng lẻ — điều này đơn giản hóa việc tính toán điểm trụ của việc thay đổi tỷ lệ.
ScaleGestureDetector là một lớp từ Android Support Library (android.view) được thiết kế để nhận dạng thay đổi tỷ lệ đa chạm. Không giống GestureDetector.onFling, ScaleGestureDetector được xây dựng đặc biệt cho thu phóng hai ngón tay và tự động tính toán focusX, focusY (điểm trung tâm giữa các ngón tay) và scaleFactor (delta tỷ lệ từ sự kiện trước).
ScaleGestureDetector hoạt động thông qua giao diện OnScaleGestureListener với ba phương thức: onScaleBegin(detector) (tương tự .began), onScale(detector) (tương tự .changed, trả về boolean — true nếu tỷ lệ được chấp nhận), onScaleEnd(detector) (tương tự .ended). Các thuộc tính: scaleFactor — hệ số nhân tỷ lệ so với sự kiện trước (~1.01 khi xoà chậm), focusX/focusY — tọa độ của điểm trung tâm giữa các ngón tay.
Để áp dụng thay đổi tỷ lệ cho một View, thường sử dụng Matrix: view.imageMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY). Matrix hỗ trợ tích lũy các phép biến đổi — mỗi lần gọi postScale thêm một phép biến đổi mới vào phép biến đổi hiện có. Điều quan trọng cần nhớ: ScaleGestureDetector không giới hạn tỷ lệ tối thiểu/tối đa — đó là trách nhiệm của nhà phát triển. Tại IT Sectr, chúng tôi sử dụng ScaleGestureDetector với các giới hạn tỷ lệ tùy chỉnh (0.5–3.0) và hoạt ảnh quay lại khi vượt quá.
Khi triển khai Pinch-to-Zoom, việc giới hạn tỷ lệ tối thiểu và tối đa là cực kỳ quan trọng — nếu không, người dùng có thể phóng to đến vô cực hoặc thu nhỏ đến mức không nhìn thấy. Giá trị tiêu chuẩn: min = 0.5 (50% kích thước gốc), max = 3.0 (300%). Trên iOS, việc giới hạn được thực hiện thông qua clamp: newScale = min(max(currentScale * gesture.scale, minScale), maxScale). Trên Android — thông qua kiểm tra sau postScale.
Điểm trụ là điểm trung tâm giữa các ngón tay, mà việc thay đổi tỷ lệ được thực hiện tương đối so với nó. iOS tự động tính toán trọng tâm của hai lần chạm và truyền nó qua location(in:) — nhà phát triển không cần tính điểm trụ thủ công. Android cung cấp focusX/focusY trong ScaleGestureDetector, nhưng khi áp dụng qua Matrix, điểm trụ được truyền dưới dạng đối số thứ ba và thứ tư trong postScale(scaleFactor, scaleFactor, focusX, focusY). Không có điểm trụ, việc thay đổi tỷ lệ sẽ được thực hiện tương đối so với góc trên cùng bên trái (0,0), làm cho nội dung bị dịch chuyển khi thu phóng.
Theo kinh nghiệm của IT Sectr, tính toán điểm trụ chính xác là nguồn lỗi phổ biến nhất khi triển khai Pinch-to-Zoom. Trong 90% trường hợp, chỉ cần sử dụng các tọa độ tích hợp của GestureRecognizer và ScaleGestureDetector là đủ — không cần tính điểm trụ thủ công. Nếu nội dung bị dịch chuyển khi thay đổi tỷ lệ, hãy kiểm tra xem bạn có đang truyền focusX/focusY cho matrix.postScale() không.
Để có trải nghiệm người dùng mượt mà sau khi cử chỉ kết thúc, bạn có thể thêm quán tính: trên iOS — sử dụng velocity từ UIPinchGestureRecognizer với UIView.animate và tham số spring; trên Android — OverScroller hoặc ValueAnimator. Quán tính không được vượt quá giới hạn tỷ lệ min/max — giá trị cuối cùng được cắt với hoạt ảnh quay lại spring khi vượt quá giới hạn.
Thêm UIPinchGestureRecognizer vào UIImageView với giới hạn tỷ lệ từ 0.5 đến 3.0. Hiển thị triển khai tối thiểu với bảo toàn tỷ lệ tích lũy.
import UIKit
class ZoomableImageViewController: UIViewController {
@IBOutlet private var imageView: UIImageView!
private var currentScale: CGFloat = 1.0
private let minScale: CGFloat = 0.5
private let maxScale: CGFloat = 3.0
override func viewDidLoad() {
super.viewDidLoad()
let pinch = UIPinchGestureRecognizer(
target: self,
action: #selector(handlePinch(_:))
)
imageView.addGestureRecognizer(pinch)
imageView.isUserInteractionEnabled = true
}
@objc private func handlePinch(_: UIPinchGestureRecognizer) {
if gestureRecognizer.state == .began
|| gestureRecognizer.state == .changed {
let newScale = currentScale * gestureRecognizer.scale
imageView.transform = CGAffineTransform(
scaleX: min(max(newScale, minScale), maxScale),
y: min(max(newScale, minScale), maxScale)
)
}
if gestureRecognizer.state == .ended {
currentScale = min(max(
currentScale * gestureRecognizer.scale,
minScale
), maxScale)
gestureRecognizer.scale = 1.0
}
}
}
Logic: trong .began/.changed, tỷ lệ được áp dụng qua CGAffineTransform với clamp. Trong .ended, tỷ lệ được đặt lại về 1.0 và giá trị tích lũy được lưu trong currentScale. Điều này ngăn chặn sự trôi tỷ lệ trong các cử chỉ lặp lại — mỗi cử chỉ mới bắt đầu từ trạng thái hiện tại. Đối với hình ảnh, nên sử dụng UIScrollView với minimumZoomScale/maximumZoomScale thay vì CGAffineTransform.
Triển khai thay đổi tỷ lệ ImageView qua ScaleGestureDetector với Matrix. Hiển thị xử lý điểm trụ chính xác và giới hạn tỷ lệ.
import android.content.Context
import android.graphics.Matrix
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.widget.ImageView
class PinchZoomImageView(context: Context) : ImageView(context) {
private val matrix = Matrix()
private var currentScale = 1f
private val minScale = 0.5f
private val maxScale = 3.0f
private val scaleDetector =
ScaleGestureDetector(context, ScaleListener())
private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
currentScale = (currentScale * detector.scaleFactor)
.coerceIn(minScale, maxScale)
matrix.postScale(
currentScale,
currentScale,
detector.focusX,
detector.focusY
)
imageMatrix = matrix
return true
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
return true
}
}
ImageView tùy chỉnh ghi đè onTouchEvent, chuyển tiếp các sự kiện đến ScaleGestureDetector. OnScaleListener.onScale() được gọi ở mỗi chuyển động ngón tay. detector.scaleFactor là delta (1.01 khi xoà chậm). currentScale tích lũy với coerceIn để clamp. focusX/focusY — điểm trung tâm giữa các ngón tay, được truyền cho postScale để thay đổi tỷ lệ điểm trụ chính xác.
UIScrollView có hỗ trợ Pinch-to-Zoom tích hợp thông qua delegate. Đây là cách tiếp cận ưa thích cho hình ảnh và tài liệu — ScrollView tự động quản lý tỷ lệ, quán tính và cuộn.
class ScrollableImageViewController: UIViewController,
UIScrollViewDelegate {
@IBOutlet private var scrollView: UIScrollView!
@IBOutlet private var imageView: UIImageView!
override func viewDidLoad() {
super.viewDidLoad()
scrollView.minimumZoomScale = 0.5
scrollView.maximumZoomScale = 3.0
scrollView.delegate = self
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}
UIScrollView tự động tạo UIPinchGestureRecognizer, quản lý việc thay đổi tỷ lệ thông qua biến đổi CALayer và thêm hiệu ứng nảy khi vượt quá giới hạn. Mã được rút gọn chỉ còn đặt min/maxZoomScale và trả về viewForZooming. Tại IT Sectr, chúng tôi sử dụng UIScrollView cho tất cả hình ảnh toàn màn hình — điều này giảm khối lượng mã đi 5 lần so với triển khai thủ công qua UIPinchGestureRecognizer.
Câu hỏi thường gặp
Trên màn hình cảm ứng, chụm sử dụng hai ngón tay với theo dõi khoảng cách giữa chúng. Trên bàn di chuột MacBook, thu phóng được triển khai thông qua UIPinchGestureRecognizer với cùng các trạng thái (.began → .changed → .ended), nhưng nguồn chạm là bàn di chuột với Force Touch, không phải màn hình điện dung. API giống nhau, cơ chế giống hệt.
Trên iOS: sau khi nhận tỷ lệ từ UIPinchGestureRecognizer, nhân với currentScale và áp dụng clamp(min: 0.5, max: 3.0). Trên Android: sử dụng coerceIn(minScale, maxScale) trước matrix.postScale(). Trong UIScrollView — thông qua các thuộc tính minimumZoomScale và maximumZoomScale. Nếu không giới hạn, tỷ lệ có thể lên đến vô cực hoặc về không.
Không, chụm yêu cầu ít nhất hai ngón tay. Để thu phóng một ngón tay (nhấp đúp + kéo lên/xuống), cần triển khai tùy chỉnh thông qua UIPanGestureRecognizer hoặc GestureDetector.onScroll. Đây là một cử chỉ khác với logic khác — thường được sử dụng để thu phóng trong máy ảnh hoặc bản đồ.
Tổng kết
Chúng tôi sẽ phát triển ứng dụng di động chìa khóa trao tay
IT Sectr tạo các ứng dụng iOS và Android cho các công ty khởi nghiệp và doanh nghiệp từ năm 2017. Chúng tôi sẽ tư vấn và đề xuất giải pháp tốt nhất cho bạn.
Đọc thêm