Draggable / Interactive image view — เพิ่ม User experience ให้กับการปิดรูปด้วยการปัดทิ้ง

Khemmachart Chutapetch
Panya Studios
Published in
9 min readNov 4, 2017

ในปัจจุบันจะเห็นได้ว่าหน้าจอของโทรศัพท์มือถือมีแนวโน้มที่จะขยายใหญ่ขึ้นเรื่อยๆ หน้าที่ของเราชาวคือสร้างแอปพลิเคชั่นที่มอบประสบการณ์การใช้งานที่ดีที่สุดให้กับผู้ใช้งาน ซึ่งประสบการณ์ที่ดีนั้นไม่ได้หมายความจะแอปพลิเคชั่นจะต้องใช้งานง่าย แต่คือการใช้งานที่เหมาะสม

หลายแอปพลิเคชั่นได้ทำการเพิ่ม User experiences ให้ผู้ใช้งานสามารถกลับไปยังหน้าที่แล้ว ด้วยการ ปัดหน้าจอปัจจุบันทิ้ง (Drag to dismiss) เพื่อแก้ปัญหาการใช้มือข้างเดียวในการถือโทรศัพท์และเอื้อมมือไปกดปุ่มปิด (Dismiss) ผู้อ่านสามารถอ่านรายละเอียดเกี่ยวกับ Dismiss animation และ Interactive transion ที่ได้บทความต่อไปนี้

Interactive modal image view — ปิดรูปด้วยการปัดทิ้ง

และสำหรับบทความนี้จะมาสอนเพิ่ม User experiences ให้กับ ViewController ที่มีหน้าที่สำหรับแสดงรูปภาพโดยเฉพาะ (ลักษณะคล้ายกับของ Facebook หรือ LINE) — หน้าตาของโปรเจคที่เสร็จแล้วก็จะเป็นประมานนี้ครับ

Image Interactive Modal Animation (Final)

ซึ่งส่วนที่จะทำนั้นมีด้วยกันสองส่วนหลักๆ คือ Dismiss Image indicator เพื่อแสดงให้ผู้ใช้งานรู้ว่าภาพนั้นถูกเปิดมาจากที่ใด — และ Drag to dismiss view controller เพื่อทำให้รูปภาพสามารถปัดได้โดยผมจะแบ่งเป็น 5 หัวข้อได้แก่

  1. Presenting full-screen image — การทำ ViewController สำหรับโชว์รูปภาพ
  2. Presenting animation — การทำแอนิเมชั่นสำหรับ Present ViewController ให้เหมือนกับขยาดรูปภาพนั้นๆ ขึ้นมาเต็มจอ
  3. Dismissal animation — การทำแอนิเมชั่นสำหรับ Dismiss ViewController ให้เหมือนกับย่อรูปภาพนั้นๆ กลับไปยังตำแหน่งเดิม
  4. Drag to dismiss — การใส่ PanGesture ให้รูปภาพสามารถถูกปัดทิ้งได้
  5. Improve the animation (Optional) — ปรับแต่งแอนิเมชั่นเลียนแบบ Facebook

Download demo project

หากผู้อ่านต้องการดูโค้ดประกอบหรือต้องการ SourceCode สามารถดาวน์โหลดได้ที่ GitHub ตามลิ้งค์ข้างล่างนี้ครับ สามารถเอาไปใช้ได้ฟรี และผมยินดีรับฟังหากมีข้อเสนอแนะหรือความเห็นครับ

โปรเจคนี้เขียนโดน Xcode 9.0 และ Swift 3 เมื่อโหลดมากดรันก็จะได้โปรเจคหน้าตาประมาณนี้ ซึ่ง Initial storyboard จะเป็น Empty View ที่ให้เลือกระหว่าง TableViewController หรือ ViewController ดังนั้นเวลารันอาจจะได้หน้าตาแตกต่างไปจากหัวข้อที่ 1–5 ซักเล็กน้อยนะครับ

Source image — http://static3.businessinsider.com

1. Presenting full-screen image- การทำ ViewController สำหรับโชว์รูปภาพ

เริ่มที่ขึ้นส่วนที่จำเป็นที่สุด คือการแสดงรูปภาพที่หน้าจอ เราจะทำให้ออกมาง่ายที่สุดก่อน คือ กดปุ่มใดๆ ก็ได้เพื่อแสดง ViewController ที่มี ImageView อยู่ข้างใน และกดปุ่ม Close เพื่อทำการปิดมัน — แต่ในความง่ายนั้น ก็มีส่วนที่สำคัญที่เราจะต้องระวังก็คือ เราจะต้องเลือก modalPresentationStyle เป็น overCurrentContext เพื่อให้สามารถมองเห็น ViewController ที่อยู่ข้างหลังได้

Present full-screen image view

ซึ่งในขั้นตอนแรกนี้จะยังไม่มีอะไรมาก นอกจากการแสดงและซ่อนรูปภาพแบบ full-screen เท่านั้นและยังไม่มีแอนนิเมชั่นอะไร เลยทำให้ผลลัพธ์ของขั้นตอนนี้ดูกระตุกๆ และแปลกๆ ซักหน่อย แต่ยังไม่ต้องแปลกใจ เพราะเราจะมาใส่แอนิเมชันให้กับมันในขั้นตอนต่อไป — สิ่งที่เราต้องทำก็มีดังนั้น

  1. สร้าง ViewController ชื่อว่า SampleButtonViewController (หรืออะไรก็ได้ตามความเหมาะสม) ซึ่งจะเป็น ViewController สำหรับ present รูปภาพของเราขึ้นมานั่นเอง — จากนั้นสร้าง UIButton โดยกำหนดรูปภาพอะไรก็ได้ให้กับปุ่ม วางตำแหน่งใดก็ได้ แต่ไม่แนะนำให้เป็นตรงกลางหน้าจอ เพื่อที่จะได้เห็นแอนนิเมชั่นอย่างชัดเจน จากนั้นอย่าต่อ Outlet ให้เรียบร้อย
  2. สร้าง ViewController ชื่อว่า InteractiveModalImageViewController (หรืออะไรก็ได้ตามความเหมาะสม) สำหรับแสดงรูปภาพของเรา
  3. สำหรับ InteractiveModalImageViewController — ตั้ง Background ของ View เป็น Clear color และเลือก Presentation — Over Current Context สองอย่างนี้จะทำให้ ViewController ของเราโปร่งใสได้เวลาถูก Present ขึ้นมา
  4. สร้าง UIView ใส่ไว้ใน InteractiveModalImageViewController ต่อกับ Outlet ชื่อว่า OverlayView, เลือก Background เป็นสีดำ และตั้ง Alpha ตามความเหมาะสม สำหรับค่า Alpha จะปรับจาก code อีกทีหนึ่ง
  5. สร้าง UIView อันที่สองต่อกับ Outlet ชื่อ ContainerView ปรับ BackgroundColor เป็น .clearColor เอาไว้สำหรับใส่ UIView ต่างๆ ที่ไม่ต้องการให้โดน Alpha — ซึ่ง UIView นี้ไม่ควรอยู่ใน OverlayView (ดูภาพ Storyboard ด้านบนประกอบ)
  6. สุดท้ายใส่ปุ่มสำหรับ Dissmiss view controller และต่อ Outlet ให้เรียบร้อย
class SampleButtonViewController: UIViewController {

@IBOutlet private weak var imageButton: UIButton!

// MARK: - Action
@IBAction func imageButtonDidPress(_ sender: UIButton) {
presentImageViewController(sender.imageView)
}
// MARK: - Util

private func presentImageViewController(_ sender: UIImageView?) {
let stroyboard = UIStoryboard(name: "Main", bundle: nil)
let sID = "InteractiveModalImageViewController"
if let viewController = stroyboard.instantiateViewController(withIdentifier: sID) as? InteractiveModalImageViewController {
viewController.image = sender?.image
present(viewController, animated: false, completion: nil)
}
}
}
  1. เมื่อผู้ใช้งานกด Button ใน SampleButtonViewController ก็จะทำการ Present แบบ model และส่งรูปภาพจากปุ่มที่กดเข้าไปใน viewContoller ที่สร้างมาจาก InteractiveModalImageViewController — และสำคัญที่สุดคือต้องส่ง animated: false ด้วยนะครับ
class InteractiveModalImageViewController: UIViewController {@IBOutlet private weak var overlayView: UIView!
@IBOutlet private weak var containerView: UIView!
@IBOutlet private weak var dismissButton: UIButton!
private lazy var displayImageView: UIImageView = {
let imageView = UIImageView(frame: self.containerView.bounds)
imageView.contentMode = .scaleAspectFit
imageView.image = self.image
imageView.frame = CGRect(x: 0,
y: 0,
width: self.view.frame.width,
height: self.view.frame.height)
return imageView
}()
var image: UIImage?// MARK: - Life cycleoverride func viewDidLoad() {
super.viewDidLoad()
setupInterface()
}
// MARK: - Action@IBAction private func dismissButtonDisPress(_ sender: UIView) {
dismiss(animated: true)
}
// MARK: - Interfaceprivate func setupInterface() {
view.addSubview(displayImageView)
}
}
  1. เมื่อ DisplayImageViewController ถูก Present ก็จะสร้าง ImageView ขึ้นมาใส่ใน ContainerView ซึ่งมีขนาดเท่ากับ View หลักและปรับ contentMode = .scaleAspectFit จากนั้นก็นำ image ที่ถูกส่งมา มาโชว์
  2. สาเหตุที่สร้าง UIImageView ผ่านโค้ดก็เพื่อจะได้ทำ Animation และ Dragging ได้ง่ายขึ้น
  3. สร้าง IBAction ให้กับ Dismiss button ในส่วนนี้เลือก animated: true ไปก่อน เพราะเราจะมา override dismiss function ในหัวข้อถัดไป

2. Presenting animation — การทำแอนิเมชั่นสำหรับ Present ViewController ให้เหมือนกับขยาดรูปภาพนั้นๆ ขึ้นมาเต็มจอ

ส่วนถัดมาเราจะมาทำแอนิเมชั่นให้ให้กับการ Present ImageViewController ซึ่งหลักการคือ เราจะขยาย ImageView frame ของเรา โดยอ้างอิงจากขนาดของ sender frame ให้เต็มหน้าจอ — หัวข้อนี้นับว่าเป็นส่วนที่ยากส่วนหนึ่งของโปรเจคนี้ เพราะฉนั้นอาจจะมีเนื้อหาที่ยาวซักหน่อยนะครับ

Presenting animation

ก่อนอื่น เราต้องคำนวณตำแหน่งของ sender frame ให้ได้ก่อน เพราะบางครั้ง sender นั้นอาจจะอยู่ภายใต้ superview อีกหลายชั้น รวมถึงอาจจะถูกส่งมาจาก TableView ที่มี ContentView ที่สูงมากๆ เป็นต้น — เราถึงต้องคำนวณ Origin ทั้งหมดของ sender และ super view ทั้งหมดที่เป็นไปได้

extension UIView {var screenOrigin: CGPoint {
return calculateScreenOrigin(from: self)
}
private func calculateScreenOrigin(from sender: UIView?) -> CGPoint {// Base case
guard let sender = sender else {
return CGPoint.zero
}
// Call recursive to get the sender actual origin
let superviewOrigin = calculateScreenOrigin(from: sender.superview)
var senderOrigin = sender.frame.origin
// If this view is kind off UITableViewCell, it need to minus with the table view offsets
if #available(iOS 11.0, *) {
if let tableViewCell = sender as? UITableViewCell,
let tableView = tableViewCell.superview as? UITableView {
senderOrigin = CGPoint(
x: sender.frame.origin.x,
y: sender.frame.origin.y - tableView.contentOffset.y)
}
} else {
if let tableViewCell = sender as? UITableViewCell,
let tableView = tableViewCell.superview?.superview as? UITableView {
senderOrigin = CGPoint(
x: sender.frame.origin.x,
y: sender.frame.origin.y - tableView.contentOffset.y)
}
}
// Return the actual origin
return CGPoint(
x: senderOrigin.x + superviewOrigin.x,
y: senderOrigin.y + superviewOrigin.y)
}
}

ผมจึงสร้าง UIView Extension ขึ้นมาเพื่อสำหรับคำนวณ screen origin ของ UIView โดยสามารถเรียกใช้ผ่านเมธอดได้เลย เช่น sender.screenOrigin เป็นต้น

จากนั้น เปิดคลาส InteractiveModalImageViewController ขึ้นมาแล้วเพิ่มตัวแปรเหล่านี้ข้างล่างลงไป เพื่อสร้าง frame ให้กับ ImageView ของเรา

var sender: UIView?private lazy var actualFrame: CGRect = {
return CGRect(x: 0,
y: 0,
width: self.view.frame.width,
height: self.view.frame.height)
}()

private lazy var senderFrame: CGRect = {
if let sender = self.sender {
let actualSenderPosition = sender.screenOrigin
return CGRect(x: actualSenderPosition.x,
y: actualSenderPosition.y,
width: sender.frame.width,
height: sender.frame.height)
}
return CGRect.zero
}()
private lazy var displayImageView: UIImageView = {
let imageView = UIImageView(frame: self.containerView.bounds)
imageView.contentMode = .scaleAspectFit
imageView.image = self.image
imageView.frame = self.senderFrame
return imageView
}()
  1. เก็บ Reference ของ sender หรือ UIView ที่ User ทำการกด เอาไว้ เพื่อนำมาคำนวน sender frame และการใช้งานในอนาคต
  2. actualFrame คือขนาดของ Full-screen image view โดยปกติแล้วควรจะมีขนาดเท่ากับ View หลัก หรือขนาดเต็มหน้าจอ
  3. senderFrame คือขนาดและตำแหน่งจริงๆ ของ sender หรือ UIView ที่ Iser ทำการกด
  4. จากเดิมที่เราสร้าง frame ขึ้นมาเองใน displayImageView ให้เปลี่ยนมาเป็นใช้ senderFame ที่เราสร้างขึ้นมา — ส่วนนี้หมายความว่า เราจะทำให้ ImageView ของเรามีขนาดเท่ากับ sender และตำแหน่งเดียวกันกับ sender
private func presentImageViewController(_ sender: UIImageView?) {
let stroyboard = UIStoryboard(name: "Main", bundle: nil)
let sID = "InteractiveModalImageViewController"

if let viewController = stroyboard.instantiateViewController(withIdentifier: sID) as? InteractiveModalImageViewController {
viewController.sender = sender
viewController.image = sender?.image
present(viewController, animated: false, completion: nil)
}
}

จากนั้นแก้ไขฟังก์ชั่น presentImageView ในคลาส SampleButtonViewController เพิ่มส่ง sender เข้าไปให้กับ InteractiveModalImageViewController ที่เราสร้างขึ้น

// MARK: - Interfaceprivate func setupInterface() {
setupInterfaceForPresentAnimation()
view.addSubview(displayImageView)
}
private func setupInterfaceForPresentAnimation() {
overlayView.alpha = OverlayViewAlpha.begin.rawValue
displayImageView.frame = senderFrame
}

private func setupInterfaceForDismissAnimation() {
overlayView.alpha = OverlayViewAlpha.done.rawValue
displayImageView.frame = actualFrame
}

// MARK: - Animation
private let duration: TimeInterval = 0.25

private func animatePresentAnimation() {
setupInterfaceForPresentAnimation()
presentAnimation()
}

private func presentAnimation() {
UIView.animate(withDuration: duration, animations: {
self.setupInterfaceForDismissAnimation()
})
}

// MARK: - Utils
private enum OverlayViewAlpha: CGFloat {
case begin = 0
case prepare = 0.75
case done = 1
}
  1. MARK: — Interface นั้นคือการคั้งค่า View ทั้งหมดให้พร้อมสำหรับการทำแอนิเมชั่น ได้แก่ setupInterfaceForPresentAnimation คือการตั้งค่าการทำแอนิเมชั่น เช่น ปรับ Alpha เป็น 0 และปรับ ImageView frame ให้เท่ากับ Sender frame
  2. และ setupInterfaceForDismissAnimation คือการตั้งค่าหลังจบแอนิเมชั่น เช่น การปรับ Alpha เป็น 1 และปรับ ImageView Frame ให้เต็มหน้าจอ
  3. MARK: — Animation คือการเรียกใช้งานแอนิเมชั่น เราจะทำการเรียกฟังก์ชั่นเพื่อปรับ View ทั้งหมดจากนั้นจึงเรียกฟังก์ชั่นแอนิเมชั่น
  4. OverlayViewAlpha เอาไว้สำหรับตั้งค่า Alpha ซึ่งจะมีการเรียกใช้ในส่วนถัดไปอีกหลายที่ จึงแยกออกมาเป็น Enumeration
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
animatePresentAnimation()
}

สุดท้ายให้ทำการเรียกใช้งานฟังก์ชั่น animatePresentAnimation() ที่ viewDidAppear(_:) และรองรันโปรเจคของเราก็จะพบกับ Presenting animation ที่เราสร้างขึ้น

3. Dismissal animation — การทำแอนิเมชั่นสำหรับ Dismiss ViewController ให้เหมือนกับย่อรูปภาพนั้นๆ กลับไปยังตำแหน่งเดิม

สำหรับส่วนการทำ Dismissal animation นั้นเรียกได้ว่าแทบจะง่ายที่สุด เพราะแค่สร้างแอนิเมชั่นขึ้นมา ส่วนการตั้งค่า Views ต่างๆ ก็ได้ทำไว้หมดแล้วในขั้นตอนที่ 2

Dismissal animation

เปิดคลาส InteractiveModalImageViewController จากนั้นใส่ฟังก์ชั่นทั้งสองนี้ลงไป

private func dismissAnimation(completionHandler handler: (() -> Void)? = nil) {
UIView.animate(withDuration: duration * 2,
delay: 0.0,
usingSpringWithDamping: 0.75,
initialSpringVelocity: 1,
options: [.curveEaseInOut],
animations: {
self.setupInterfaceForPresentAnimation()
}, completion: { complete in
handler?()
})
}
  1. สร้างแอนิเมชั่นขึ้นมาโดย Views ทั้งหมดจะถูกตั้งค่าให้กลับไปค่าเริ่มต้น จากการเรียกฟังก์ชั่น setupInterfaceForPresentAnimation
  2. ในฟังก์ชั่น UIView.animate() ผมได้ใส่ parameters ต่างๆ เข้าไปให้มีเอฟเฟกส์วูบๆ (Bounces) ตรงนี้ถ้าใครรู้สึกว่ามันมากเกินไปสามารถลบออกได้
  3. สุดท้ายเรียกใช้งาน handler หลังจากที่จบแอนิเมชั่น ในส่วนนี้จะใส่ฟังก์ชั่น dismiss เข้ามาให้เรียกใช้งาน
override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
if flag {
dismissAnimation(completionHandler: {
super.dismiss(animated: false, completion: completion)
})
} else {
super.dismiss(animated: false, completion: completion)
}
}
  1. สิ่งสุดท้ายสำหรับหัวข้อนี้คือการ overide ฟังก์ชั่น dimiss โดยหาก Animate flag เป็น true ให้เรียกใช้งาน Dismissal แอนิเมชั่นที่เราสร้างขึ้น จากนั้นก็ Dismiss view controller เมื่อแอนิเมชั่นของเราสมบูรณ์
  2. แต่หากว่า Animate flag เป็น false ก็ให้มัน Dismiss view controller เฉยๆ ได้เลย (ต้องเรียกใช้งานผ่าน super.dismiss() นะครับไม่งั้นจะเกิดเป็น Infinite loop ได้

4. Drag to dismiss image — การใส่ PanGesture ให้รูปภาพสามารถถูกปัดทิ้งได้

มาถึงหัวข้อส่วนที่ยากอีกส่วนของบทความนี้ นั่นก็คือการ Drag to dismiss view controller หรือผู้ใช้งานสามารถปิดรูปภาพได้โดยการ “ปัดทิ้ง” ไม่จำเป็นต้องเอื้อมไปกดปุ่ม Close อีกต่อไป — หลักการของขั้นตอนนี้คือเราจะทำการดักจับ Event การปัดของผู้ใช้งาน และเมื่อผู้ใช้งานทำการปล่อยนิ้ว เราก็จะคำนวนว่าระยะที่ปัดทั้งหมดนั้นตรงกับเงื่อนไขในการ Dismiss หรือไม่ — ถ้าหากใช่ก็จะแสดงแอนิเมชั่นและทำการ Dismiss แต่ถ้าไม่ก็จะแสดงแอนิเมชั่นให้ View นั้นกลับไปที่เดิม

Drag to dismiss image

ซึ่งในส่วนนี้ต้องทำการแก้ไขไฟล์ Storyboard ซักเล็กน้อย เพราะเราจำเป็นต้องดัก Event สำหรับการปัดด้วยการใส่ Pan Gesture Recognizer ให้กับ Interactive Modal Image View Controller ในไฟล์ Storyboard ของเรา

จากนั้นก็ต่อ IBAction ของ PanGestureRecognizer กับฟังก์ชั่น handlePanGesture(_ sender: UIPanGestureRecognizer)

@IBAction private func handlePanGesture(_ sender: UIPanGestureRecognizer) {let touchPoint = sender.location(in: self.view?.window)
switch sender.state {
case .began:
initialTouchPoint = touchPoint
case .changed:
if overlayView.alpha > OverlayViewAlpha.prepare.rawValue {
setupInterfaceForDismissAnimationPreparation()
}
let yPosition = touchPoint.y - initialTouchPoint.y
let xPosition = touchPoint.x - initialTouchPoint.x
displayImageView.frame = CGRect(x: xPosition,
y: yPosition,
width: view.bounds.width,
height: view.bounds.height)
case .ended, .cancelled:
if isReachedDismissPosition(curPosition: touchPoint) {
dismiss(animated: true)
} else {
presentAnimation()
}
default:
break
}
}

สำหรับการดักจับ Event ของ UIPanGestureRecognizer จะแบ่งเป็น 3States คือ

  1. Began คือเริ่มปัด ให้เก็บจุดที่เริ่มเปิด (initialTouchPoint) เอาไว้
  2. Changed เมื่อผู้ใช้งานปัดหรือลาด ImageView ก็จะขยับ ImageView ไปยังตำแหน่งที่ผู้ใช้งานวาดนิ้วไป พร้อมทั้งปรับ Alpha ของ OverlayView ให้ผู้ใช้งานรู้ว่านี่คือการ Dismiss view controller
  3. Ended หรือ Cancelled คือการปล่อยนิ้วมือ หรือยกเลิกการปัด ให้ทำการตรวจสอบว่าตำแหน่งสุดท้ายที่ผู้ใช้งานวาดนิ้วมือมานั้นตรงกับเงื่อนไขที่จะ Dismiss view controller หรือไม่ หากเงื่อนไขเป็นจริงก็ให้แสดง Dismissal animation แต่หากไม่เป็นจริงก็ให้ ImageView นั้นกลับไปอยู่ตำแหน่งเดิม
// MARK: - Interfaceprivate func setupInterfaceForDismissAnimationPreparation() {
UIView.animate(withDuration: duration, animations: {
self.overlayView.alpha = OverlayViewAlpha.prepare.rawValue
})
}

เพิ่มฟังก์ชั่นสำหรับการทำ Alpha ให้กับ OverlayView ขณะที่ผู้ใช้งานทำการวาดนิ้วลงบน UIImageView

private var initialTouchPoint = CGPoint(x: 0,y: 0)
private let dragingDismissDistance: CGFloat = 80
private func isReachedDismissPosition(curPosition: CGPoint) -> Bool {
let isOverYPosition = abs(curPosition.y - initialTouchPoint.y) > dragingDismissDistance
let isOverXPosition = abs(curPosition.x - initialTouchPoint.x) > dragingDismissDistance
return isOverYPosition || isOverXPosition
}

จากนั้นสร้างตัวแปรที่จำเป็นได้แก่ initialTouchPoint สำหรับเก็บจุดเริ่มต้นในการปัด และ dragingDismissDistance หรือระยะที่จะให้ทำการ Dismiss view controller

จากนั้นสร้างฟังก์ชั่นขึ้นมาอีกอันโดยมีหน้าที่ ตรวจสอบจุดสุดท้ายที่ผู้ใช้งานวาดนิ้ว (หรือจุดที่ผู้ใช้งานปล่อยนิ้วมือ) หากว่ามีระยะมากกว่าที่เรากำหนดไว้ใน dragingDismissDistance ก็ให้ทำการ Dismiss view controller

5. Improve the animation (Optional) — ปรับแต่งแอนิเมชั่นเลียนแบบ Facebook

หากลองย้อนกลับไปดูแอนิเมชั่นของ Facebook ดีๆ เราจะเห็นว่าในขณะที่รูปภาพกำลังจะถูก Present ขึ้นมานั้น ตัว ViewController ที่ทำการ Present รูปภาพนั้นจะยุบลงไปนิดนึง และในทางกลับกันในตอนที่รูปภาพถูก Dismiss ตัว ViewController ที่ทำการ Present รูปภาพก็จะขยายขึ้นมาเต็มหน้าจอเหมือนเดิม

อีกกรณี สังเกตุได้จาก Facebook และ Pinterest เมื่อกำลังจะ Dismissal นั้น View ทุกอย่างจะหายหมดไปจากหน้าจอ เหลือแค่ ImageView นั้นเอง

กรณีสุดท้าย คือ รูปภาพที่ถูก Present ขึ้นมานั้น ไม่ควรจะซ้ำกันอยู่สองที่ หมายความว่าเมื่อรูปถูก Present ขึ้นมาแล้ว ตัว Sender เองนั้นก็ควรจะหายไปด้วย เพื่อนๆ สามารถดูตัวอย่างแอนิเมชั่นของ Facebook หรือ Demo ประกอบได้ที่ข้างล่าง

Improve animation

สำหรับวิธีการก้ไม่ยาก เพราะส่วนที่ยากกว่านี้เราได้ทำไปหมดแล้ว อันดับแรก เราจะมาทำให้ ViewController ที่ Present รูปภาพ สามารถยุบๆ ยวบๆ ได้

var superview: UIView?private func setupInterfaceForPresentAnimation() {
overlayView.alpha = OverlayViewAlpha.begin.rawValue
displayImageView.frame = senderFrame
dismissButton.isHidden = true
superview?.transform = CGAffineTransform(scaleX: 1, y: 1)
}
private func setupInterfaceForDismissAnimation() {
overlayView.alpha = OverlayViewAlpha.done.rawValue
displayImageView.frame = actualFrame
dismissButton.isHidden = false
superview?.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
}
private func setupInterfaceForDismissAnimationPreparation() {
UIView.animate(withDuration: duration, animations: {
self.dismissButton.isHidden = true
self.overlayView.alpha = OverlayViewAlpha.prepare.rawValue
})
}
  1. อันดับแรกสร้างตัวแปรชื่อ superview ขึ้นมาเพื่อ Reference กลับไปยัง View ของ ViewController ที่ Present รูปภาพนี้ขึ้นมา
  2. แก้ไขฟังก์ชั่นสำหรับตั้งค่า Interface ทั้งหมด โดยเราจะซ่อน Views ที่ไม่เกี่ยวข้องทั้งหมดใน setupInterfaceForPresentAnimation และ setupInterfaceForDismissAnimationPreparation (ปรับ isHidden = true) และโชว์ Views เหล่านั้นใน setupInterfaceForDismissAnimation (ปรับ isHidden = false)
  3. ปรับ superview?.transform = CGAffineTransform(scaleX: 1, y: 1) ใน setupInterfaceForPresentAnimation และปรับ superview?.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) ใน setupInterfaceForDismissAnimation เพื่อให้ superview รู้สึกยุบลงไปเวลา Present รูปภาพ — โดยผู้อ่านสามารถอ่านรายละเอียดเพิ่มเติมเกี่ยวกั options ต่าง ได้ที่ https://medium.com/@RobertGummesson/a-look-at-uiview-animation-curves-part-1-191d9e6de0ab
var presentHandler: (() -> Void)? = nil
var dismissHandler: (() -> Void)? = nil
private func animatePresentAnimation() {
presentHandler?()
setupInterfaceForPresentAnimation()
presentAnimation()
}
private func dismissAnimation(completionHandler handler: (() -> Void)? = nil) {
UIView.animate(withDuration: duration * 2,
delay: 0.0,
usingSpringWithDamping: 0.75,
initialSpringVelocity: 1,
options: [.curveEaseInOut],
animations: {
self.setupInterfaceForPresentAnimation()
}, completion: { complete in
self.dismissHandler?()
handler?()
})
}
  1. สร้าง Handler สำหรับ Present และ Dismissal เนื่องจากการซ่อน Sender นั้นเราจำเป็นต้องสั่งจาก ViewController ที่ Present รูปภาพนี้ขึ้นมา ดังนั้นการเรียกแบบ CallBack (ซึ่งในที่นี้ตั้งชื่อว่า Handler) จึงตอบโจทย์ที่สุด
  2. เรียก Present Handler ก่อนที่จะเรียก Present Animation
  3. เรียก Dismiss Handler หลังจากที่โชว์ Dismiss animation เรียบร้อยแล้ว
// MARK: - Action@IBAction func imageButtonDidPress(_ sender: UIButton) {
presentImageViewController(
sender.imageView,
presentHandler: { self.imageButton.isHidden = true },
dismissHandler: { self.imageButton.isHidden = false })
}
// MARK: - Utilprivate func presentImageViewController(
_ sender: UIImageView?,
presentHandler: (() -> Void)? = nil,
dismissHandler: (() -> Void)? = nil) {
let stroyboard = UIStoryboard(name: "Main", bundle: nil)
let sID = "InteractiveModalImageViewController"
if let viewController = stroyboard.instantiateViewController(withIdentifier: sID) as? InteractiveModalImageViewController {
viewController.superview = view
viewController.sender = sender
viewController.image = sender?.image
viewController.dismissHandler = dismissHandler
viewController.presentHandler = presentHandler
present(viewController, animated: false, completion: nil)
}
}

สุดท้าย ย้อนกลับมาที่ SampleButtonViewController ให้ทำการแก้ไขฟังก์ชั่นในการ InteractiveModalImageViewController โดยส่งตัวแปรที่เพิ่มเข้ามาได้แก่ superview, presentHandler, dismissHandler — และเราก็ทำการ ซ่อนและโชว์ sender ใน presentHandler และ dismissHandler นั่นเอง

Table View Cell Supported

นอกจาก ViewController ธรรมดาแล้วยังสามารถใช้กับ TableViewCell ได้ด้วย หลักการคล้ายๆ กันเพียงแต่ต้องเรียกผ่าน Delegator หรือ CallBack อีกทีนึงเพื่อที่จะได้อ้างถึง ImageView ใน TableViewCell

Table view cell
protocol SampleTableViewCellDelegate: class {func presentImageFrom(imageView: UIImageView?, atCell cell: SampleTableViewCell)
}
class SampleTableViewCell: UITableViewCell {weak var delegate: SampleTableViewCellDelegate?@IBOutlet weak var imageButton: UIButton!
@IBAction func imageButtonDidPress(_ sender: UIButton) {
delegate?.presentImageFrom(imageView: sender.imageView, atCell: self)
}
}

ตัวอย่างการเรียก Delegate ของ TableViewCell สิ่งที่เราจำเป็นต้องส่งกลับออกไปคือ ImageView (sender) และ Cell หรือตัวมันเองนั่นเอง

extension SampleTableViewController: SampleTableViewCellDelegate {func presentImageFrom(imageView: UIImageView?, atCell cell: SampleTableViewCell) {
presentImageViewController(
imageView,
presentHandler: { cell.imageButton.isHidden = true },
dismissHandler: { cell.imageButton.isHidden = false })
}
}

หลังจากที่ ViewController รับ Delegator method มาก็ทำการ Present ImageViewController และสั่ง cell.imageButton.isHidden = true เป็นอันเรียบร้อย

Where to go from here

ผู้อ่านสามารถดาวโหลด Demo project ได้ที่นี่

การเพิ่ม User Experiences นั้นมีอีกหลายวิธี เช่น Drag to dismiss view controller, Drag to dismiss table view controller, Drag to pop navigation controller ซึ่งวิธีการเหล่านี้จะช่วยลดปัญหาในการใช้งานมือถือด้วยมือข้างเดียวของผู้ใช้งานได้ ในขณะที่โทรศัพท์มือถือมีแนวโน้มที่จะขยายขนาดหน้าจอให้ใหญ่มากขึ้นเรื่อยๆ หน้าที่ของเรา Developer คือทำอย่างไรให้แอปพลิเคชั่นของเราใช้งานได้ง่ายที่สุด

--

--