티스토리 뷰

최근 다른 카테고리 포스팅에는 개인적으로 느낀 난이도 별로 Beginner, Advenced, Master로 나눠서 작성했는데 IOS는 하나의 애플리케이션을 만드는 과정을 나누어 담다 보니 Training이라는 등급으로 퉁 쳐서 작성하고 있다. 이 등급은 그렇게 큰 의미가 없는 것 같다. 분류 방법을 다시 생각해 봐야겠다.

서버로부터 데이터도 받아 오겠다 끌어내려서 새로고침 되는 기능을 만들어 보자. UITableView, UIScrollView 등 끌어내리는 동작을 할 수 있는 view들은 보통 UIRefreshControl 이라는 view를 가지고 있어서 이걸 통해서 indicator를 보이게 하거나 이벤트를 처리할 수도 있지만 Profile은 sticky header autolayout이 동작하고 있기 때문에 스크롤 위쪽으로 하얗게 배경이 보이는 게 썩 마음에 들지 않는다.

 

이건 좀....

 

UIActivityIndicatorView라는 빙글빙글 돌아가는 view가 있으니 이 view를 사용해서 직접 만들어 보자.

 

 

 

layout

indicator를 추가하기 전에 스크롤을 아래로 끌어내렸을 때 즉, 데이터를 로딩하고 있을 때 상단에 공백이 유지될 수 있게 layout을 수정해야 한다.

 

흰색 배경이 아니라 sticky header가 적용된 상태에서 공백이 유지되어야 한다.

 

UIScrollView는 정해진 범위 안에서만 스크롤 할 수 있도록 설계되어 있다. contentOffset을 정해진 inset 즉, contentInset, adjustedContentInset으로 제한된 범위 안에 들어오도록 유지하려는 관성을 가진다. 예를 들어 스크롤 범위 안에서 손을 때면 그 자리에 멈춰 있지만, 범위를 벗어나면 알아서 가장자리로 돌아온다.

UIScrollViewDelegate는 다양한 scroll event를 처리할 수 있도록 이벤트 처리 함수를 제공하는데, 사용자가 스크롤에서 손을 뗐을 때에는scrollViewDidEndDragging 이벤트가 호출된다. 스크롤을 멈췄을 때 새로고침을 해야 하는 임계점(refreshCritical)보다 더 많이 스크롤 되어 있다면 contentInset으로 상단부에 공백을 준다. 이 자리가 바로 indicator가 보이게 될 자리다. 그리고 이 시점에 서버로 데이터 요청을 보내는 것이 적절해 보인다.

 

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
	if scrollView.contentOffset.y < refreshCritical {
		scrollView.contentInset.top = 50
		// request data
		// completion -> call restore(data)
	}
}

 

sticky header를 구현하기 위해 offset을 계산 했었는데, 이제 contentInset을 추가했으니, adjustedContentOffset 뿐 아니라 contentInset도 계산하도록 수정한다.

 

func scrollViewDidScroll(_ scrollView: UIScrollView) {
	let offset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top - scrollView.contentInset.top
	// update constraint with offset ...
}

 

위에서 request를 요청하면서 응답에 대한 처리를 restore 함수를 호출하는 것으로 했다. restore에서는 응답을 받았으니 추가한 inset을 초기화 하고 응답 받은 데이터에 대한 처리를 할 수 있다.

하지만 여기에서 contentInset을 다시 0으로 수정하면 부드럽게 돌아오지 않는다. 무슨 이유에서인지 contentInset을 지정하면 contentOffset도 바뀐다. offset을 저장해 두었다가 다시 지정해 준다.

 

func restore(_ data: Data) {
	// data
	let storedOffset = self.contentOffset.y
	self.contentInset.top = 0
	self.contentOffset.y = storedOffset
}

 

 

 

UIActivityIndicatorView

빙글빙글 돌아가는 로딩을 화면에 그릴 수 있다. startAnimating(), stopAnimating() 으로 제어할 수 있다. 기본적으로 animation이 재생중일 때만 화면에 보이도록 설정되어 있지만 변경할 수 있다.

 

적당히 화면 상단에 추가한다.

 

scroll view에 indicator를 추가하고 위에서 inset을 추가하고 제거했던 코드에 적절히 start/stopAnimating()을 추가하면 스크롤을 끌어 내렸을 때 indicator가 돌아가는 모습을 볼 수 있다.

 

// in initialize
let indicator = UIActivityIndicatorView(O
indicator.style = .large
scrollView.addSubview(indicator)
// constraints...

// in restore
indicator.stopAnimating()

// in scrollViewDidEndDragging
indicator.startAnimating()

 

 

 

custom scroll view

모든 것을 ProfileViewController에서 생성하고 처리하도록 하면 view controller의 몸집이 너무 커지게 된다. sticky header와, 새로고침 기능을 가진, 나만의 scroll view를 만들어서 기능을 분리해 보자. 이름은 TDScrollView라고 지었다.

 

이건 개인마다 방법이 다를 수 있으니 정답은 없다.

 

새로운 파일을 만들고 UIScrollView를 상속받는 클래스를 만든다. delegate를 self로 지정하고, sticky header, body, indicator 등을 추가해서 기본 뼈대를 구성하는데, 이건 원래 있던 코드의 위치만 옮겼을 뿐이기 때문에 코드는 생략한다.

delegate protocol을 만들어서 request에 대한 응답이 왔을 때 외부에서 처리할 수 있도록 한다. 이 delegate는 ProfileViewController가 채택하여 새로고침에 대한 처리를 할 수 있도록 만들 것이다.

 

protocol TDRefreshDelegate {
	func needToRefresh(_ data: Data)
}

 

데이터를 요청하는 함수도 request와 task처리 부분을 분리한다. TDScrollView는 새로고침을 할 때 이 request를 보내며, 응답이 오면 delegate를 통해 needToRefresh 함수를 호출한다.

 

func generateRequest(path: String, method: String, items: [URLQueryItem]) -> URLRequest? {
	guard let urlStr = (serverURL + "/" + path).addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
		print("failed to encode url")
		return nil
	}
        
	var urlComponent = URLComponents(string: urlStr)
	urlComponent?.queryItems = items
        
	guard let url = urlComponent?.url else {
		print("failed to make url")
		return nil
	}
        
	var request = URLRequest(url: url)
	request.httpMethod = method
        
	return request
}

func runTask(request: URLRequest?, completion: @escaping(_: Data) -> Void) {
	guard let request = request else {
		return
	}
        
	let session = URLSession.shared
	session.dataTask(with: request, completionHandler: {data, response, error in
            
		guard let data = data else {
			print("nil response data")
			return
		}
            
		completion(data)
	}).resume()
}

 

 

 

use

다시 TDScrollView로 돌아가서 새로고침이 필요할 때 request를 보낼 수 있도록 한다.

 

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
	if scrollView.contentOffset.y < refreshCritical {
		scrollView.contentInset.top = 50
		// start loading
		indicator.startAnimating()
		dataSource.runTask(request: refreshRequest, completion: {data in
			DispatchQueue.main.async {
				self.restore(data)
			}
		})
	}
}

 

restore에서는 delegate의 needToRefresh를 호출한다.

 

func restore(_ data: Data) {
	let storedOffset = self.contentOffset.y
	self.contentInset.top = 0
	self.contentOffset.y = storedOffset

	// stop loading
	indicator.stopAnimating()

	refreshDelegate?.needToRefresh(data)
}

 

needToRefresh에서는 전달받은 data를 decode하여 view의 내용을 새로고침 한다.

 

 

 

Result

처음에는 로컬 데이터로 view를 초기화 했고, 아래로 끌어당겨서 새로고침을 요청하면 서버로부터 데이터를 받아서 내용이 바뀌는 것을 볼 수 있다.

 

 

layout이나 데이터 구조 설계하는 부분은 사람마다 다르게 만들 수 있는데, 이번 포스팅에서는 기능적인 부분보다 그러한 부분에 대한 수정이 많았다. TDScrollView를 따로 만든 것도 순전히 내 스타일이고, 그래서 코드를 많이 생략하려고 노력했는데 잘 안된 것 같다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함